@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
@@ -141,6 +141,22 @@ body {
141
141
  opacity: 1;
142
142
  }
143
143
 
144
+ /* Audio clips read as a distinct kind — a persistent violet tint vs the teal-
145
+ accented visual clips — so the audio zone at the bottom is visually separate
146
+ at a glance (the timeline's kinds: visual on top, audio below). */
147
+ .timeline-clip.is-audio {
148
+ background-color: rgba(167, 139, 250, 0.16);
149
+ border-color: rgba(167, 139, 250, 0.4);
150
+ }
151
+
152
+ .timeline-clip.is-audio.is-hovered {
153
+ background-color: rgba(167, 139, 250, 0.24);
154
+ }
155
+
156
+ .timeline-clip.is-audio.is-dragging {
157
+ background-color: rgba(60, 52, 84, 0.96);
158
+ }
159
+
144
160
  .timeline-clip.is-hovered {
145
161
  background-color: rgba(255, 255, 255, 0.09);
146
162
  }
@@ -158,9 +174,20 @@ body {
158
174
  }
159
175
 
160
176
  .timeline-clip.is-dragging {
177
+ /* Solid background so the picked-up clip reads clearly while dragging — audio
178
+ clips (waveform drawn on a separate layer) would otherwise look transparent. */
179
+ background-color: rgba(38, 42, 52, 0.96);
161
180
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
162
181
  }
163
182
 
183
+ /* Keep the white selection outline while dragging (a plain box-shadow alone gets
184
+ overridden by .is-dragging's drop shadow, so re-state both together). */
185
+ .timeline-clip.is-selected.is-dragging {
186
+ box-shadow:
187
+ 0 0 0 1.5px rgba(255, 255, 255, 0.85),
188
+ 0 8px 24px rgba(0, 0, 0, 0.4);
189
+ }
190
+
164
191
  .timeline-clip[data-active].is-selected.is-dragging {
165
192
  box-shadow:
166
193
  0 0 0 1.5px rgba(255, 255, 255, 0.85),
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { findClipForAsset, isPointerClick, DRAG_THRESHOLD_PX } from "./assetClickBehavior";
3
+ import type { TimelineElement } from "../player/store/playerStore";
4
+
5
+ // Minimal TimelineElement factory — only the fields the function inspects.
6
+ function makeEl(overrides: Partial<TimelineElement> & { id: string }): TimelineElement {
7
+ return {
8
+ tag: "div",
9
+ start: 0,
10
+ duration: 5,
11
+ track: 0,
12
+ ...overrides,
13
+ };
14
+ }
15
+
16
+ describe("findClipForAsset", () => {
17
+ it("returns null when elements array is empty", () => {
18
+ expect(findClipForAsset([], "assets/foo.mp4")).toBeNull();
19
+ });
20
+
21
+ it("returns null when no element matches", () => {
22
+ const el = makeEl({ id: "el1", src: "assets/other.mp4" });
23
+ expect(findClipForAsset([el], "assets/foo.mp4")).toBeNull();
24
+ });
25
+
26
+ it("matches a bare relative src against the project-relative asset path", () => {
27
+ const el = makeEl({ id: "el1", src: "assets/clip.mp4", start: 2 });
28
+ expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
29
+ });
30
+
31
+ it("matches a src with a ./ prefix", () => {
32
+ const el = makeEl({ id: "el1", src: "./assets/logo.png" });
33
+ expect(findClipForAsset([el], "assets/logo.png")).toBe(el);
34
+ });
35
+
36
+ it("matches a server-relative /api/projects/…/preview/ src", () => {
37
+ const el = makeEl({ id: "el1", src: "/api/projects/demo/preview/assets/bgm.mp3" });
38
+ expect(findClipForAsset([el], "assets/bgm.mp3")).toBe(el);
39
+ });
40
+
41
+ it("matches a fully-absolute URL (as produced by the core runtime)", () => {
42
+ const el = makeEl({
43
+ id: "el1",
44
+ src: "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4",
45
+ });
46
+ expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
47
+ });
48
+
49
+ it("decodes percent-encoded filenames when matching", () => {
50
+ const el = makeEl({
51
+ id: "el1",
52
+ src: "http://localhost:3012/api/projects/p/preview/assets/my%20file%20(1).mp4",
53
+ });
54
+ expect(findClipForAsset([el], "assets/my file (1).mp4")).toBe(el);
55
+ });
56
+
57
+ it("strips query strings from src before matching", () => {
58
+ const el = makeEl({ id: "el1", src: "assets/clip.mp4?v=2" });
59
+ expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
60
+ });
61
+
62
+ it("returns the element with the earliest start when multiple clips match", () => {
63
+ const later = makeEl({ id: "late", src: "assets/clip.mp4", start: 10 });
64
+ const earlier = makeEl({ id: "early", src: "assets/clip.mp4", start: 2 });
65
+ const first = makeEl({ id: "first", src: "assets/clip.mp4", start: 0 });
66
+ expect(findClipForAsset([later, earlier, first], "assets/clip.mp4")).toBe(first);
67
+ });
68
+
69
+ it("prefers the key over id when the element has both", () => {
70
+ // findClipForAsset returns the element object itself; callers do `clip.key ?? clip.id`
71
+ const el = makeEl({ id: "el1", key: "clip-key", src: "assets/img.png" });
72
+ const found = findClipForAsset([el], "assets/img.png");
73
+ expect(found?.key).toBe("clip-key");
74
+ });
75
+
76
+ it("skips elements with no src", () => {
77
+ const noSrc = makeEl({ id: "nosrc" });
78
+ const withSrc = makeEl({ id: "withsrc", src: "assets/img.png" });
79
+ expect(findClipForAsset([noSrc, withSrc], "assets/img.png")).toBe(withSrc);
80
+ });
81
+ });
82
+
83
+ describe("isPointerClick", () => {
84
+ it("returns true for zero movement", () => {
85
+ expect(isPointerClick(0, 0)).toBe(true);
86
+ });
87
+
88
+ it("returns true for movement within the threshold", () => {
89
+ expect(isPointerClick(DRAG_THRESHOLD_PX - 1, 0)).toBe(true);
90
+ expect(isPointerClick(0, DRAG_THRESHOLD_PX - 1)).toBe(true);
91
+ expect(isPointerClick(DRAG_THRESHOLD_PX - 1, DRAG_THRESHOLD_PX - 1)).toBe(true);
92
+ });
93
+
94
+ it("returns false at or beyond the threshold", () => {
95
+ expect(isPointerClick(DRAG_THRESHOLD_PX, 0)).toBe(false);
96
+ expect(isPointerClick(0, DRAG_THRESHOLD_PX)).toBe(false);
97
+ expect(isPointerClick(DRAG_THRESHOLD_PX + 10, 0)).toBe(false);
98
+ });
99
+
100
+ it("handles negative movement (pointer moved left/up)", () => {
101
+ expect(isPointerClick(-(DRAG_THRESHOLD_PX - 1), 0)).toBe(true);
102
+ expect(isPointerClick(-DRAG_THRESHOLD_PX, 0)).toBe(false);
103
+ });
104
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Pure helpers for CapCut-style asset card click behavior.
3
+ *
4
+ * Clicking an asset card that is ALREADY ADDED to the timeline selects the
5
+ * corresponding clip. Clicking one NOT yet in the timeline opens a lightweight
6
+ * preview overlay. Both behaviors are gated on "this was a click, not a drag".
7
+ *
8
+ * Pure — unit-tested.
9
+ */
10
+ import type { TimelineElement } from "../player/store/playerStore";
11
+
12
+ /**
13
+ * Find the TimelineElement that references `assetPath`, returning the one with
14
+ * the earliest start time when multiple clips share the same source.
15
+ *
16
+ * Matching mirrors `deriveUsedPaths` in AssetsTab: an element's `src` may be a
17
+ * fully-absolute URL, a server-relative `/api/projects/…/preview/…` path, a
18
+ * `./`-prefixed relative path, or a bare relative path — all normalised to the
19
+ * project-relative form that `assetPath` carries.
20
+ *
21
+ * Returns `null` when no element matches.
22
+ */
23
+ export function findClipForAsset(
24
+ elements: TimelineElement[],
25
+ assetPath: string,
26
+ ): TimelineElement | null {
27
+ let best: TimelineElement | null = null;
28
+ for (const el of elements) {
29
+ if (!el.src) continue;
30
+ if (normalizeSrc(el.src) !== assetPath) continue;
31
+ if (best === null || el.start < best.start) best = el;
32
+ }
33
+ return best;
34
+ }
35
+
36
+ /**
37
+ * Normalise a raw element `src` to the bare project-relative path so it can be
38
+ * compared against the asset-list strings (which have no leading slash, no
39
+ * origin, no query string).
40
+ *
41
+ * Mirrors the logic in `deriveUsedPaths` (AssetsTab.tsx) — keep in sync.
42
+ */
43
+ function normalizeSrc(src: string): string {
44
+ let s = src;
45
+ try {
46
+ const u = new URL(s);
47
+ s = u.pathname;
48
+ } catch {
49
+ // Not an absolute URL — leave as-is
50
+ }
51
+ s = s
52
+ .replace(/^\/api\/projects\/[^/]+\/preview\//, "")
53
+ .replace(/^\.?\//, "")
54
+ .split(/[?#]/)[0];
55
+ try {
56
+ s = decodeURIComponent(s);
57
+ } catch {
58
+ // Malformed encoding — use as-is
59
+ }
60
+ return s;
61
+ }
62
+
63
+ /** Drag-detection threshold in pixels — movements within this are treated as clicks. */
64
+ export const DRAG_THRESHOLD_PX = 4;
65
+
66
+ /**
67
+ * Determine whether a pointer-up event should be treated as a click given the
68
+ * total pointer displacement since pointer-down.
69
+ *
70
+ * @param dx Horizontal distance moved in pixels.
71
+ * @param dy Vertical distance moved in pixels.
72
+ */
73
+ export function isPointerClick(dx: number, dy: number): boolean {
74
+ return Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX;
75
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Tiny Zustand slice that carries the "asset preview overlay" state.
3
+ *
4
+ * When a user clicks an asset card that has NOT yet been added to the
5
+ * timeline the overlay fires up: a dark scrim + centered media element
6
+ * (img / video / audio) + filename label rendered inside PreviewPane.
7
+ *
8
+ * State lives here so AssetsTab (sidebar) and PreviewPane (preview column)
9
+ * can communicate without prop-drilling through the multi-layer EditorShell
10
+ * tree. The store is project-scoped: NLEProvider (NLEContext.tsx) clears it
11
+ * whenever `projectId` changes, so a preview opened in one project can't
12
+ * bleed into another (the overlay itself stays mounted across project
13
+ * switches — EditorShell isn't keyed by projectId).
14
+ */
15
+ import { create } from "zustand";
16
+
17
+ interface AssetPreviewState {
18
+ /** Project-relative asset path currently being previewed, or null. */
19
+ previewAsset: string | null;
20
+ /** projectId for which the preview was opened (used to build the serve URL). */
21
+ previewProjectId: string | null;
22
+ /** Open a media preview for the given asset. */
23
+ setPreviewAsset: (asset: string, projectId: string) => void;
24
+ /** Close the preview overlay. */
25
+ clearPreviewAsset: () => void;
26
+ }
27
+
28
+ export const useAssetPreviewStore = create<AssetPreviewState>((set) => ({
29
+ previewAsset: null,
30
+ previewProjectId: null,
31
+ setPreviewAsset: (asset, projectId) => set({ previewAsset: asset, previewProjectId: projectId }),
32
+ clearPreviewAsset: () => set({ previewAsset: null, previewProjectId: null }),
33
+ }));
@@ -2,9 +2,10 @@ import type { RegistryItem } from "@hyperframes/core/registry";
2
2
  import type { TimelineElement } from "../player";
3
3
  import {
4
4
  insertTimelineAssetIntoSource,
5
- resolveTimelineAssetInitialGeometry,
5
+ resolveTimelineAssetCompositionSize,
6
6
  } from "./timelineAssetDrop";
7
7
  import { collectHtmlIds } from "./studioHelpers";
8
+ import { generateId } from "./generateId";
8
9
  import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
9
10
  import { saveProjectFilesWithHistory } from "./studioFileHistory";
10
11
  import type { EditHistoryKind } from "./editHistory";
@@ -120,7 +121,9 @@ export async function addBlockToProject(
120
121
  );
121
122
 
122
123
  const isBlock = block.type === "hyperframes:block";
123
- const hostDims = resolveTimelineAssetInitialGeometry(originalContent);
124
+ const { width: hostWidth, height: hostHeight } =
125
+ resolveTimelineAssetCompositionSize(originalContent);
126
+ const hostDims = { left: 0, top: 0, width: hostWidth, height: hostHeight };
124
127
 
125
128
  const currentTime = opts.currentTime ?? 0;
126
129
  const start = placement
@@ -152,6 +155,11 @@ export async function addBlockToProject(
152
155
 
153
156
  const subCompHtml = [
154
157
  `<div`,
158
+ // A stable id (+ hf-id) is what authored sub-comps carry; without it the
159
+ // timeline can't dedup the host and renders duplicate clips that multiply
160
+ // on every interaction. Matches the authored-comp shape.
161
+ ` id="${compId}"`,
162
+ ` data-hf-id="hf-${generateId()}"`,
155
163
  ` data-composition-id="${compId}"`,
156
164
  ` data-composition-src="${compositionFile}"`,
157
165
  ` data-start="${formatTimelineAttributeNumber(start)}"`,
@@ -0,0 +1,31 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { __resetForTests, acquireCanvasNudgeKeys, canvasNudgeKeysClaimed } from "./canvasNudgeGate";
3
+
4
+ describe("canvasNudgeGate", () => {
5
+ // The claim counter is module-level state; reset it so an unbalanced claim in one
6
+ // test can't leak into the next.
7
+ beforeEach(() => {
8
+ __resetForTests();
9
+ });
10
+
11
+ it("reports claimed while at least one claim is held", () => {
12
+ expect(canvasNudgeKeysClaimed()).toBe(false);
13
+ const releaseA = acquireCanvasNudgeKeys();
14
+ const releaseB = acquireCanvasNudgeKeys();
15
+ expect(canvasNudgeKeysClaimed()).toBe(true);
16
+ releaseA();
17
+ expect(canvasNudgeKeysClaimed()).toBe(true);
18
+ releaseB();
19
+ expect(canvasNudgeKeysClaimed()).toBe(false);
20
+ });
21
+
22
+ it("makes release idempotent so an effect re-run cannot underflow", () => {
23
+ const release = acquireCanvasNudgeKeys();
24
+ release();
25
+ release();
26
+ expect(canvasNudgeKeysClaimed()).toBe(false);
27
+ const releaseNext = acquireCanvasNudgeKeys();
28
+ expect(canvasNudgeKeysClaimed()).toBe(true);
29
+ releaseNext();
30
+ });
31
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Arrow-key ownership gate between the canvas nudge (DomEditOverlay) and the
3
+ * playback frame-step shortcuts (usePlaybackKeyboard). Both listen for arrow
4
+ * keys with window capture listeners, and their relative order depends on
5
+ * mount order (DomEditOverlay remounts when caption edit mode toggles), so
6
+ * `event.defaultPrevented` alone can't arbitrate. While a nudgeable canvas
7
+ * selection holds a claim, the playback handler skips ArrowLeft/ArrowRight.
8
+ */
9
+
10
+ let claims = 0;
11
+
12
+ /** Claim the arrow keys for canvas nudging. Returns an idempotent release. */
13
+ export function acquireCanvasNudgeKeys(): () => void {
14
+ claims += 1;
15
+ let released = false;
16
+ return () => {
17
+ if (released) return;
18
+ released = true;
19
+ claims -= 1;
20
+ };
21
+ }
22
+
23
+ /** True while a nudgeable canvas selection owns the arrow keys. */
24
+ export function canvasNudgeKeysClaimed(): boolean {
25
+ return claims > 0;
26
+ }
27
+
28
+ /** Test-only: reset the module-level claim counter so leaked claims from one test
29
+ * can't bleed into the next (call in a `beforeEach`). */
30
+ export function __resetForTests(): void {
31
+ claims = 0;
32
+ }
@@ -209,6 +209,41 @@ describe("edit history", () => {
209
209
  expect(state.undo[0].files["index.html"].after).toBe("c");
210
210
  });
211
211
 
212
+ it("merges a lane-change move with its z-reorder past the default window via entry coalesceMs", () => {
213
+ // The z entry records only after the move persist's round-trip — often >300ms.
214
+ // Both sides pass coalesceMs: 5000 with the shared gesture key so the pair
215
+ // still folds into ONE undo step.
216
+ const move = buildEditHistoryEntry({
217
+ projectId: "project-1",
218
+ label: "Move timeline clips",
219
+ kind: "timeline",
220
+ coalesceKey: "clip-lane-move:1",
221
+ coalesceMs: 5000,
222
+ files: { "index.html": { before: "a", after: "b" } },
223
+ now: 100,
224
+ id: "move-entry",
225
+ });
226
+ const zReorder = buildEditHistoryEntry({
227
+ projectId: "project-1",
228
+ label: "Reorder layers",
229
+ kind: "manual",
230
+ coalesceKey: "clip-lane-move:1",
231
+ coalesceMs: 5000,
232
+ files: { "index.html": { before: "b", after: "c" } },
233
+ now: 500,
234
+ id: "z-entry",
235
+ });
236
+
237
+ const state = pushEditHistoryEntry(
238
+ pushEditHistoryEntry(createEmptyEditHistory(), move),
239
+ zReorder,
240
+ );
241
+
242
+ expect(state.undo).toHaveLength(1);
243
+ expect(state.undo[0].files["index.html"].before).toBe("a");
244
+ expect(state.undo[0].files["index.html"].after).toBe("c");
245
+ });
246
+
212
247
  it("folds a slow GSAP follow-up into the timing edit via a per-entry coalesceMs override", () => {
213
248
  const timing = buildEditHistoryEntry({
214
249
  projectId: "project-1",
@@ -1,7 +1,12 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { describe, it, expect, vi } from "vitest";
4
- import { applySoftReload, ensureMotionPathPluginLoaded } from "./gsapSoftReload";
4
+ import {
5
+ applySoftReload,
6
+ ensureMotionPathPluginLoaded,
7
+ diffSoftReloadableRestore,
8
+ applyUndoRestoreToPreview,
9
+ } from "./gsapSoftReload";
5
10
 
6
11
  const SCRIPT_TEXT = `
7
12
  window.__timelines = window.__timelines || {};
@@ -447,3 +452,116 @@ describe("applySoftReload authored-opacity restore", () => {
447
452
  expect(restoreOpacity(el)).toBe("");
448
453
  });
449
454
  });
455
+
456
+ // ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
457
+
458
+ const wrap = (body: string) => `<html><body>${body}</body></html>`;
459
+
460
+ describe("diffSoftReloadableRestore", () => {
461
+ it("reports the changed id for an attribute/inline-style-only diff", () => {
462
+ const prev = wrap(`<div id="a" style="translate: 10px 10px">t</div>`);
463
+ const next = wrap(`<div id="a" style="translate: 0px 0px">t</div>`);
464
+ expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: ["a"] });
465
+ });
466
+
467
+ it("treats a structural change (added element) as NOT soft-reloadable", () => {
468
+ const prev = wrap(`<div id="a">t</div>`);
469
+ const next = wrap(`<div id="a">t</div><div id="a-split">t</div>`);
470
+ expect(diffSoftReloadableRestore(prev, next)).toBeNull();
471
+ });
472
+
473
+ it("treats an element text/child change as NOT soft-reloadable", () => {
474
+ const prev = wrap(`<div id="a">one</div>`);
475
+ const next = wrap(`<div id="a">two</div>`);
476
+ expect(diffSoftReloadableRestore(prev, next)).toBeNull();
477
+ });
478
+
479
+ it("allows a GSAP-script-only change (no id'd-attribute diff)", () => {
480
+ const prev = wrap(
481
+ `<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
482
+ );
483
+ const next = wrap(
484
+ `<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:9});</script>`,
485
+ );
486
+ expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: [] });
487
+ });
488
+ });
489
+
490
+ function buildLiveIframe(bodyHtml: string) {
491
+ const doc = document.implementation.createHTMLDocument("");
492
+ doc.body.innerHTML = bodyHtml;
493
+ const contentWindow = {
494
+ gsap: { timeline: () => {} },
495
+ __hfForceTimelineRebind: () => {},
496
+ __timelines: {} as Record<string, unknown>,
497
+ __player: { getTime: () => 3, seek: vi.fn() },
498
+ __hfStudioManualEditsApply: vi.fn(),
499
+ };
500
+ return {
501
+ iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
502
+ contentWindow,
503
+ doc,
504
+ };
505
+ }
506
+
507
+ describe("applyUndoRestoreToPreview", () => {
508
+ const ROOT = "index.html";
509
+
510
+ it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
511
+ const { iframe, contentWindow, doc } = buildLiveIframe(
512
+ `<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
513
+ );
514
+ const reloadPreview = vi.fn();
515
+ const files = {
516
+ [ROOT]: {
517
+ previous: wrap(
518
+ `<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
519
+ ),
520
+ restored: wrap(`<div id="a" style="translate: 0px 0px" data-hf-path-offset="true">t</div>`),
521
+ },
522
+ };
523
+ const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview);
524
+ expect(outcome).toBe("soft");
525
+ expect(reloadPreview).not.toHaveBeenCalled();
526
+ // Live element reverted to the restored inline style.
527
+ expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px");
528
+ // No GSAP script in the restore → the manual-edit reapply runs, playhead held.
529
+ expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
530
+ expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
531
+ });
532
+
533
+ it("full-reloads a multi-file restore", () => {
534
+ const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
535
+ const reloadPreview = vi.fn();
536
+ const files = {
537
+ [ROOT]: {
538
+ previous: wrap(`<div id="a" style="x">t</div>`),
539
+ restored: wrap(`<div id="a">t</div>`),
540
+ },
541
+ "scenes/intro.html": { previous: "a", restored: "b" },
542
+ };
543
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
544
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
545
+ });
546
+
547
+ it("full-reloads a structural restore (split/delete undo)", () => {
548
+ const { iframe } = buildLiveIframe(`<div id="a">t</div><div id="a-split">t</div>`);
549
+ const reloadPreview = vi.fn();
550
+ const files = {
551
+ [ROOT]: {
552
+ previous: wrap(`<div id="a">t</div><div id="a-split">t</div>`),
553
+ restored: wrap(`<div id="a">t</div>`),
554
+ },
555
+ };
556
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
557
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
558
+ });
559
+
560
+ it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
561
+ const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
562
+ const reloadPreview = vi.fn();
563
+ const files = { "scenes/intro.html": { previous: "a", restored: "b" } };
564
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
565
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
566
+ });
567
+ });