@hyperframes/studio 0.7.54 → 0.7.56

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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -6,12 +6,10 @@ import {
6
6
  } from "../player/components/timelineEditing";
7
7
  import { getElementZIndex } from "../player/lib/layerOrdering";
8
8
  import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers";
9
- import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
10
- import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
11
- import type { EditHistoryKind } from "../utils/editHistory";
9
+ import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
12
10
  import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
13
11
  import { extendRootDurationInSource } from "../utils/rootDuration";
14
-
12
+ export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
15
13
  function isHTMLElement(element: Element | null): element is HTMLElement {
16
14
  if (!element) return false;
17
15
  // Use the element's OWN realm's HTMLElement: timeline clips live in the preview
@@ -20,7 +18,6 @@ function isHTMLElement(element: Element | null): element is HTMLElement {
20
18
  const Ctor = element.ownerDocument?.defaultView?.HTMLElement ?? globalThis.HTMLElement;
21
19
  return element instanceof Ctor;
22
20
  }
23
-
24
21
  /**
25
22
  * Resolve a timeline vertical move to a z-index stacking reorder and commit it
26
23
  * through the shared layers-panel reorder path. Reads live sibling z-index from
@@ -38,13 +35,12 @@ export function applyTimelineStackingReorder(input: {
38
35
  iframe: HTMLIFrameElement | null;
39
36
  activeCompPath: string | null;
40
37
  commit: TimelineZIndexReorderCommit | null | undefined;
38
+ coalesceKey?: string;
41
39
  }): Promise<void> {
42
40
  // Audio has no visual stacking; a vertical drag on it must never write z-index.
43
41
  if (input.element.tag === "audio") return Promise.resolve();
44
-
45
42
  const intent = input.stackingReorder ?? null;
46
43
  if (intent == null || intent.zIndexChanges.length === 0) return Promise.resolve();
47
-
48
44
  // Resolve each change's live element from the change's OWN locator (the intent
49
45
  // is self-contained), falling back to the top-level element list. Sub-comp
50
46
  // children aren't in `timelineElements`, so a list-only lookup would miss them.
@@ -67,7 +63,6 @@ export function applyTimelineStackingReorder(input: {
67
63
  sourceFile: string;
68
64
  key: string;
69
65
  }> = [];
70
-
71
66
  for (const change of intent.zIndexChanges) {
72
67
  const sibling = siblingByKey.get(change.key);
73
68
  const domId = change.domId ?? sibling?.domId;
@@ -86,48 +81,17 @@ export function applyTimelineStackingReorder(input: {
86
81
  key: change.key,
87
82
  });
88
83
  }
89
-
90
84
  if (commitEntries.length === 0) return Promise.resolve();
91
- return input.commit?.(commitEntries) ?? Promise.resolve();
92
- }
93
-
94
- /**
95
- * Remove the keyframes currently selected in the player store from the active
96
- * element's GSAP animation. Reads selection lazily so it stays correct when
97
- * invoked from a ref callback. Extracted from StudioApp to keep it under the
98
- * studio 600-LOC cap.
99
- */
100
- export function deleteSelectedKeyframes(session: {
101
- selectedGsapAnimations: readonly { id: string; keyframes?: unknown }[];
102
- handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
103
- }): void {
104
- const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
105
- const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes);
106
- if (!animation) return;
107
- // Only the active element's keyframes; a stale cross-element selection must not delete here.
108
- for (const pct of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) {
109
- session.handleGsapRemoveKeyframe(animation.id, pct);
110
- }
85
+ return input.commit?.(commitEntries, input.coalesceKey) ?? Promise.resolve();
111
86
  }
112
-
113
87
  export function extendRootDurationIfNeeded(newEnd: number): boolean {
114
88
  const store = usePlayerStore.getState();
115
89
  if (newEnd <= store.duration) return false;
116
90
  store.setDuration(newEnd);
117
91
  return true;
118
92
  }
119
-
120
93
  // ── Types ──
121
-
122
- export interface RecordEditInput {
123
- label: string;
124
- kind: EditHistoryKind;
125
- coalesceKey?: string;
126
- /** Per-entry coalesce window override (ms); lets a slow follow-up still merge. */
127
- coalesceMs?: number;
128
- files: Record<string, { before: string; after: string }>;
129
- }
130
-
94
+ export type { RecordEditInput } from "../utils/studioFileHistory";
131
95
  export function buildPatchTarget(element: {
132
96
  domId?: string;
133
97
  hfId?: string;
@@ -150,9 +114,7 @@ export function buildPatchTarget(element: {
150
114
  }
151
115
  return null;
152
116
  }
153
-
154
117
  export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
155
-
156
118
  // The runtime re-reads data-start/data-duration from the DOM on each sync tick
157
119
  // (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
158
120
  // picked up automatically on the next frame without a rebind call.
@@ -172,7 +134,6 @@ export function findTimelineElementInIframe(
172
134
  return null;
173
135
  }
174
136
  }
175
-
176
137
  export function patchIframeDomTiming(
177
138
  iframe: HTMLIFrameElement | null,
178
139
  element: TimelineElement,
@@ -186,7 +147,6 @@ export function patchIframeDomTiming(
186
147
  // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
187
148
  }
188
149
  }
189
-
190
150
  function postRootDurationToPreview(
191
151
  iframe: HTMLIFrameElement | null,
192
152
  durationSeconds: number,
@@ -203,7 +163,6 @@ function postRootDurationToPreview(
203
163
  "*",
204
164
  );
205
165
  }
206
-
207
166
  // fallow-ignore-next-line complexity
208
167
  function resolveResizePlaybackStart(
209
168
  original: string,
@@ -236,12 +195,26 @@ export function buildTimelineMoveTimingPatch(
236
195
  target: PatchTarget,
237
196
  start: number,
238
197
  duration: number,
198
+ track?: number,
239
199
  ): string {
240
- const patched = applyPatchByTarget(original, target, {
200
+ if (!Number.isFinite(start) || !Number.isFinite(duration)) {
201
+ console.warn(
202
+ `[Timeline] buildTimelineMoveTimingPatch: non-finite timing (start=${start}, duration=${duration}) — patch skipped`,
203
+ );
204
+ return original;
205
+ }
206
+ let patched = applyPatchByTarget(original, target, {
241
207
  type: "attribute",
242
208
  property: "start",
243
209
  value: formatTimelineAttributeNumber(start),
244
210
  });
211
+ if (track != null && Number.isFinite(track)) {
212
+ patched = applyPatchByTarget(patched, target, {
213
+ type: "attribute",
214
+ property: "track-index",
215
+ value: formatTimelineAttributeNumber(track),
216
+ });
217
+ }
245
218
  return extendRootDurationInSource(patched, start + duration);
246
219
  }
247
220
 
@@ -595,5 +568,6 @@ export function foldedScaleGsapMutation(input: {
595
568
  });
596
569
  }
597
570
 
598
- // Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
599
571
  export { applyPatchByTarget, formatTimelineAttributeNumber };
572
+
573
+ export { patchDocumentRootDuration } from "./timelineEditingGsap";
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { TimelineElement } from "../player";
3
+ import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter";
4
+
5
+ const element = (id: string, track: number): TimelineElement => ({
6
+ id,
7
+ key: id,
8
+ tag: "div",
9
+ start: 0,
10
+ duration: 2,
11
+ track,
12
+ });
13
+
14
+ describe("persistTimelineMoveEditsAtomically", () => {
15
+ it("persists two vertical edits as one group with the gesture coalesce key", async () => {
16
+ const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
17
+ const edits = [
18
+ { element: element("a", 0), updates: { start: 1, track: 1 } },
19
+ { element: element("b", 1), updates: { start: 3, track: 2 } },
20
+ ];
21
+ await persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "track-insert", {
22
+ handleTimelineGroupMove,
23
+ });
24
+ expect(handleTimelineGroupMove).toHaveBeenCalledTimes(1);
25
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith(
26
+ [
27
+ { element: edits[0].element, start: 1, track: 1 },
28
+ { element: edits[1].element, start: 3, track: 2 },
29
+ ],
30
+ { coalesceKey: "clip-lane-move:7" },
31
+ );
32
+ });
33
+
34
+ it("does not persist track attrs for a single z-only lane reorder", async () => {
35
+ const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
36
+ const edit = { element: element("a", 0), updates: { start: 1, track: 1 } };
37
+ await persistTimelineMoveEditsAtomically([edit], "clip-lane-move:7", "lane-reorder", {
38
+ handleTimelineGroupMove,
39
+ });
40
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith([{ element: edit.element, start: 1 }], {
41
+ coalesceKey: "clip-lane-move:7",
42
+ });
43
+ });
44
+
45
+ it("does not persist track attrs for a multi-selection lane drag", async () => {
46
+ const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
47
+ const edits = [
48
+ { element: element("a", 0), updates: { start: 1, track: 1 } },
49
+ { element: element("b", 2), updates: { start: 3, track: 2 } },
50
+ ];
51
+ await persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "lane-reorder", {
52
+ handleTimelineGroupMove,
53
+ });
54
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith(
55
+ [
56
+ { element: edits[0].element, start: 1 },
57
+ { element: edits[1].element, start: 3 },
58
+ ],
59
+ { coalesceKey: "clip-lane-move:7" },
60
+ );
61
+ });
62
+
63
+ it("rejects without retrying individual members when the atomic batch fails", async () => {
64
+ const failure = new Error("batch failed");
65
+ const handleTimelineGroupMove = vi.fn().mockRejectedValue(failure);
66
+ const edits = [
67
+ { element: element("a", 0), updates: { start: 1, track: 1 } },
68
+ { element: element("b", 1), updates: { start: 3, track: 2 } },
69
+ ];
70
+ await expect(
71
+ persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "track-insert", {
72
+ handleTimelineGroupMove,
73
+ }),
74
+ ).rejects.toBe(failure);
75
+ expect(handleTimelineGroupMove).toHaveBeenCalledTimes(1);
76
+ });
77
+ });
@@ -0,0 +1,37 @@
1
+ import type { TimelineElement } from "../player";
2
+ import type {
3
+ TimelineGroupCommitOptions,
4
+ TimelineGroupMoveChange,
5
+ } from "./useTimelineGroupEditing";
6
+
7
+ interface MoveEdit {
8
+ element: TimelineElement;
9
+ updates: Pick<TimelineElement, "start" | "track">;
10
+ }
11
+
12
+ interface AtomicMoveDeps {
13
+ handleTimelineGroupMove: (
14
+ changes: TimelineGroupMoveChange[],
15
+ options?: TimelineGroupCommitOptions,
16
+ ) => Promise<void>;
17
+ }
18
+
19
+ export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert";
20
+
21
+ export function persistTimelineMoveEditsAtomically(
22
+ edits: MoveEdit[],
23
+ coalesceKey: string | undefined,
24
+ operation: TimelineMoveOperation,
25
+ deps: AtomicMoveDeps,
26
+ ): Promise<void> {
27
+ return deps.handleTimelineGroupMove(
28
+ edits.map(({ element, updates }) => ({
29
+ element,
30
+ start: updates.start,
31
+ // A single vertical edit is the z-only reorder path. Multi-edit gestures
32
+ // are track inserts/ripples and must persist every resulting lane index.
33
+ track: operation === "track-insert" ? updates.track : undefined,
34
+ })),
35
+ { coalesceKey },
36
+ );
37
+ }
@@ -1,12 +1,12 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import React, { act } from "react";
4
- import { createRoot } from "react-dom/client";
5
4
  import { afterEach, describe, expect, it, vi } from "vitest";
6
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
7
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
8
7
  import { usePlayerStore } from "../player/store/playerStore";
9
8
  import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
9
+ import { mountReactHarness } from "./domSelectionTestHarness";
10
10
 
11
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
12
 
@@ -58,13 +58,7 @@ function renderHookWith(
58
58
  onReady(commitAnimatedProperties);
59
59
  return null;
60
60
  }
61
- const host = document.createElement("div");
62
- document.body.append(host);
63
- const root = createRoot(host);
64
- act(() => {
65
- root.render(<Harness />);
66
- });
67
- return root;
61
+ return mountReactHarness(<Harness />);
68
62
  }
69
63
 
70
64
  function renderCommitHook(
@@ -163,4 +157,38 @@ describe("commitStaticSet group routing", () => {
163
157
  expect(update!.mutation.animationId).toBe("#box-set-0-position");
164
158
  expect(update!.label).toBe("Move layer");
165
159
  });
160
+
161
+ it("width edit updates its duration-zero size hold instead of appending a set", async () => {
162
+ const instantSizeHold = {
163
+ id: "#box-to-0-size",
164
+ targetSelector: "#box",
165
+ propertyGroup: "size",
166
+ method: "to",
167
+ properties: { width: 150, height: 150 },
168
+ resolvedStart: 0,
169
+ duration: 0,
170
+ extras: { immediateRender: "__raw:true" },
171
+ } as unknown as GsapAnimation;
172
+ const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
173
+ let commit!: Commit;
174
+ renderHookWith(
175
+ [positionSet, instantSizeHold],
176
+ (mutation, label) => committed.push({ mutation, label }),
177
+ (c) => (commit = c),
178
+ );
179
+
180
+ await act(async () => {
181
+ await commit(selection, { width: 344 });
182
+ });
183
+
184
+ expect(committed).toHaveLength(1);
185
+ expect(committed[0]!.mutation).toEqual({
186
+ type: "update-properties",
187
+ animationId: instantSizeHold.id,
188
+ properties: { width: 344 },
189
+ });
190
+ expect(committed[0]!.label).toBe("Resize layer");
191
+ expect(committed.some(({ mutation }) => mutation.type === "add")).toBe(false);
192
+ expect(committed[0]!.mutation.animationId).not.toBe(positionSet.id);
193
+ });
166
194
  });
@@ -14,7 +14,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
14
14
  import { usePlayerStore } from "../player/store/playerStore";
15
15
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
16
16
  import type { SetPatchProps } from "./gsapRuntimePatch";
17
- import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
17
+ import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared";
18
18
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
19
19
  import { roundTo3 } from "../utils/rounding";
20
20
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
@@ -161,8 +161,8 @@ async function commitSetProps(
161
161
  /**
162
162
  * Static element (no keyframes on ANY of its tweens): persist the 3D props as a
163
163
  * `tl.set` — NEVER keyframes. Mirrors manual drag / resize / rotate, which `tl.set`
164
- * a static element instead of animating it. Updates an existing `set` in place, or
165
- * creates a dedicated `set` at position 0 when the element has none.
164
+ * a static element instead of animating it. Updates an existing same-group static
165
+ * hold in place, or creates a dedicated `set` at position 0 when the element has none.
166
166
  */
167
167
  async function commitStaticSet(
168
168
  selection: DomEditSelection,
@@ -172,12 +172,12 @@ async function commitStaticSet(
172
172
  commit: Commit,
173
173
  ): Promise<void> {
174
174
  if (!selector) return;
175
- // One commit per PROPERTY GROUP, each into a set that owns that group — never a
176
- // flat `to`/`from`, and never a foreign-group set (a width edit used to merge
177
- // into the element's position set, producing a mixed set the split machinery
178
- // exists to prevent). Within a group everything batches into ONE commit: a
179
- // set's id is group-derived, so a per-prop loop would shift the id mid-way and
180
- // 404 the next update.
175
+ // One commit per PROPERTY GROUP, each into a static write that owns that group —
176
+ // never a live tween, and never a foreign-group write (a width edit used to
177
+ // merge into the element's position set, producing a mixed write the split
178
+ // machinery exists to prevent). Within a group everything batches into ONE
179
+ // commit: a write's id is group-derived, so a per-prop loop would shift the id
180
+ // mid-way and 404 the next update.
181
181
  const byGroup = new Map<string, [string, number | string][]>();
182
182
  for (const entry of propEntries) {
183
183
  const group = classifyPropertyGroup(entry[0]);
@@ -185,24 +185,24 @@ async function commitStaticSet(
185
185
  batch.push(entry);
186
186
  byGroup.set(group, batch);
187
187
  }
188
- const sets = animations.filter((a) => a.method === "set" && a.targetSelector === selector);
188
+ const staticWrites = animations.filter((a) => isInstantHold(a) && a.targetSelector === selector);
189
189
  // Resolve every group's target BEFORE committing anything, and coalesce
190
- // groups that land on the SAME set into one commit: the `sets` snapshot is
191
- // captured once, so if two groups resolved to one legacy mixed set, a first
190
+ // groups that land on the SAME write into one commit: the snapshot is captured
191
+ // once, so if two groups resolved to one legacy mixed write, a first
192
192
  // commit could re-shape it server-side and leave the second chasing a stale
193
193
  // id (404 on legacy pre-split files).
194
- const byTargetSet = new Map<GsapAnimation, [string, number | string][]>();
194
+ const byTargetWrite = new Map<GsapAnimation, [string, number | string][]>();
195
195
  const newSetBatches: [string, number | string][][] = [];
196
196
  for (const [group, batch] of byGroup) {
197
- const existingSet = findGroupOwningSet(sets, group);
198
- if (existingSet) {
199
- byTargetSet.set(existingSet, [...(byTargetSet.get(existingSet) ?? []), ...batch]);
197
+ const existingWrite = findGroupOwningStaticWrite(staticWrites, group);
198
+ if (existingWrite) {
199
+ byTargetWrite.set(existingWrite, [...(byTargetWrite.get(existingWrite) ?? []), ...batch]);
200
200
  } else {
201
201
  newSetBatches.push(batch);
202
202
  }
203
203
  }
204
- for (const [targetSet, batch] of byTargetSet) {
205
- await commitSetProps(selection, targetSet, batch, selector, animations, commit);
204
+ for (const [targetWrite, batch] of byTargetWrite) {
205
+ await commitSetProps(selection, targetWrite, batch, selector, animations, commit);
206
206
  }
207
207
  // Fresh adds don't reshape existing sets, so their ids can't go stale.
208
208
  for (const batch of newSetBatches) {
@@ -211,14 +211,19 @@ async function commitStaticSet(
211
211
  }
212
212
 
213
213
  /**
214
- * The set that owns a property group: one already dedicated to the group wins;
215
- * else a mixed set that already carries a property of the group (merging
216
- * same-group values there beats spawning a second writer for the channel).
214
+ * The static write that owns a property group: one already dedicated to the
215
+ * group wins; else a mixed write that already carries a property of the group
216
+ * (merging same-group values there beats spawning a second writer for the channel).
217
217
  */
218
- function findGroupOwningSet(sets: GsapAnimation[], group: string): GsapAnimation | undefined {
218
+ function findGroupOwningStaticWrite(
219
+ staticWrites: GsapAnimation[],
220
+ group: string,
221
+ ): GsapAnimation | undefined {
219
222
  return (
220
- sets.find((a) => a.propertyGroup === group) ??
221
- sets.find((a) => Object.keys(a.properties).some((k) => classifyPropertyGroup(k) === group))
223
+ staticWrites.find((a) => a.propertyGroup === group) ??
224
+ staticWrites.find((a) =>
225
+ Object.keys(a.properties).some((k) => classifyPropertyGroup(k) === group),
226
+ )
222
227
  );
223
228
  }
224
229
 
@@ -445,8 +450,8 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
445
450
  }
446
451
 
447
452
  // Existing static hold on a NON-animated element — merge the props into the
448
- // `set` in place (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
449
- if (anim?.method === "set") {
453
+ // same write (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
454
+ if (anim && isInstantHold(anim)) {
450
455
  await commitSetProps(
451
456
  selection,
452
457
  anim,
@@ -4,7 +4,7 @@ import type { TimelineElement } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
5
  import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
6
6
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
7
- import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
7
+ import { isEditableTarget } from "../utils/timelineDiscovery";
8
8
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
9
  import { canSplitElement } from "../utils/timelineElementSplit";
10
10
  import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
@@ -81,6 +81,8 @@ interface HistoryResult {
81
81
  reason?: string;
82
82
  label?: string;
83
83
  paths?: string[];
84
+ /** Per-file restored/previous content, used to soft-apply the preview. */
85
+ files?: Record<string, { previous: string; restored: string }>;
84
86
  }
85
87
  interface HistoryFileCallbacks {
86
88
  readFile: (path: string) => Promise<string>;
@@ -96,7 +98,6 @@ interface EditHistoryHandle {
96
98
  }
97
99
 
98
100
  interface UseAppHotkeysParams {
99
- toggleTimelineVisibility: () => void;
100
101
  handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
101
102
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
102
103
  handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
@@ -108,7 +109,10 @@ interface UseAppHotkeysParams {
108
109
  writeProjectFile: (path: string, content: string) => Promise<void>;
109
110
  domEditSaveTimestampRef: React.MutableRefObject<number>;
110
111
  showToast: (message: string, tone?: "error" | "info") => void;
111
- syncHistoryPreviewAfterApply: (paths: string[] | undefined) => Promise<void>;
112
+ syncHistoryPreviewAfterApply: (restore: {
113
+ paths?: string[];
114
+ files?: Record<string, { previous: string; restored: string }>;
115
+ }) => Promise<void>;
112
116
  waitForPendingDomEditSaves: () => Promise<void>;
113
117
  leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
114
118
  handleCopy: () => boolean;
@@ -135,7 +139,6 @@ interface UseAppHotkeysParams {
135
139
  // ── Extracted keydown dispatch (pure function, no hooks) ──
136
140
 
137
141
  interface HotkeyCallbacks {
138
- toggleTimelineVisibility: () => void;
139
142
  handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
140
143
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
141
144
  handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
@@ -291,9 +294,14 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
291
294
  return;
292
295
  }
293
296
  }
294
- const { selectedElementId, elements } = usePlayerStore.getState();
295
- if (selectedElementId) {
296
- const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
297
+ // Delete acts on the primary selection OR the marquee multi-selection —
298
+ // the delete handler expands a clip that is part of the multi-selection
299
+ // into an atomic delete of the whole selection (single undo).
300
+ const { selectedElementId, selectedElementIds, elements } = usePlayerStore.getState();
301
+ const selectionKeys = new Set(selectedElementIds);
302
+ if (selectedElementId) selectionKeys.add(selectedElementId);
303
+ if (selectionKeys.size > 0) {
304
+ const el = elements.find((e) => selectionKeys.has(e.key ?? e.id));
297
305
  if (el) {
298
306
  event.preventDefault();
299
307
  void cb.handleTimelineElementDelete(el);
@@ -317,7 +325,6 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
317
325
  // ── Hook ──
318
326
 
319
327
  export function useAppHotkeys({
320
- toggleTimelineVisibility,
321
328
  handleTimelineElementDelete,
322
329
  handleTimelineElementSplit,
323
330
  handleDomEditElementDelete,
@@ -346,15 +353,6 @@ export function useAppHotkeys({
346
353
  const previewHotkeyWindowRef = useRef<Window | null>(null);
347
354
  const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
348
355
 
349
- const handleTimelineToggleHotkey = useCallback(
350
- (event: KeyboardEvent) => {
351
- if (!shouldHandleTimelineToggleHotkey(event)) return;
352
- event.preventDefault();
353
- toggleTimelineVisibility();
354
- },
355
- [toggleTimelineVisibility],
356
- );
357
-
358
356
  // ── Undo / Redo ──
359
357
 
360
358
  const readHistoryFile = useCallback(
@@ -397,7 +395,7 @@ export function useAppHotkeys({
397
395
  if (activeCompPath && result.paths?.includes(activeCompPath)) {
398
396
  forceReloadSdkSession?.();
399
397
  }
400
- await syncHistoryPreviewAfterApply(result.paths);
398
+ await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files });
401
399
  showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info");
402
400
  }
403
401
  },
@@ -421,7 +419,6 @@ export function useAppHotkeys({
421
419
 
422
420
  const cbRef = useRef<HotkeyCallbacks>(null!);
423
421
  cbRef.current = {
424
- toggleTimelineVisibility,
425
422
  handleTimelineElementDelete,
426
423
  handleTimelineElementSplit,
427
424
  handleDomEditElementDelete,
@@ -444,11 +441,6 @@ export function useAppHotkeys({
444
441
 
445
442
  const handleAppKeyDown = useCallback((event: KeyboardEvent) => {
446
443
  const cb = cbRef.current;
447
- if (shouldHandleTimelineToggleHotkey(event)) {
448
- event.preventDefault();
449
- cb.toggleTimelineVisibility();
450
- return;
451
- }
452
444
  const key = event.key.toLowerCase();
453
445
  if (event.metaKey || event.ctrlKey) {
454
446
  dispatchModifierKey(event, key, cb);
@@ -537,6 +529,5 @@ export function useAppHotkeys({
537
529
  handleRedo,
538
530
  syncPreviewTimelineHotkey,
539
531
  syncPreviewHistoryHotkey,
540
- handleTimelineToggleHotkey,
541
532
  };
542
533
  }