@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
@@ -4,7 +4,6 @@
4
4
  * Owns: onPointerMove, onPointerUp, clearPointerState.
5
5
  * startGesture and startGroupDrag live in domEditOverlayStartGesture.ts.
6
6
  */
7
- import { setElementGsapPosition } from "../../utils/elementGsap";
8
7
  import type { RefObject } from "react";
9
8
  import { type DomEditSelection } from "./domEditing";
10
9
  import {
@@ -30,36 +29,29 @@ import {
30
29
  import {
31
30
  type GroupOverlayItem,
32
31
  type OverlayRect,
32
+ orientedOverlayRect,
33
33
  resolveDomEditGroupOverlayRect,
34
- toOverlayRect,
35
34
  } from "./domEditOverlayGeometry";
36
35
  import {
37
36
  BLOCKED_MOVE_THRESHOLD_PX,
38
37
  type GestureKind,
39
38
  type GestureState,
40
39
  type GroupGestureState,
40
+ type ResizeHandle,
41
41
  type UseDomEditOverlayGesturesOptions,
42
+ ROTATED_SNAP_BYPASS_DEGREES,
42
43
  hasDomEditRotationChanged,
43
- resolveDomEditResizeGesture,
44
44
  resolveDomEditRotationGesture,
45
45
  } from "./domEditOverlayGestures";
46
+ import { resolveCenterResizeSize } from "./domEditResizeLocal";
47
+ import { resolveResizeDraftRect } from "./resizeDraft";
46
48
  import {
47
49
  startGesture as _startGesture,
48
50
  startGroupDrag as _startGroupDrag,
49
51
  } from "./domEditOverlayStartGesture";
50
52
  import { hugRectForElement } from "./domEditOverlayCrop";
51
- import {
52
- resolveSnapAdjustment,
53
- resolveResizeSnapAdjustment,
54
- resolveEquidistanceGuides,
55
- SNAP_THRESHOLD_PX,
56
- } from "./snapEngine";
57
- /** Undo the resize draft's anchor pin: snap GSAP x/y back to the gesture base. */
58
- function restoreResizeAnchorPin(element: HTMLElement, g: GestureState): void {
59
- const anchor = g.resizeAnchor;
60
- if (!anchor || (anchor.pinX === 0 && anchor.pinY === 0)) return;
61
- setElementGsapPosition(element, anchor.baseGsapX, anchor.baseGsapY);
62
- }
53
+ import { resolveSnapAdjustment, resolveEquidistanceGuides, SNAP_THRESHOLD_PX } from "./snapEngine";
54
+ import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDebug";
63
55
 
64
56
  export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
65
57
  const setDraftOverlayRect = (next: OverlayRect) => {
@@ -73,6 +65,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
73
65
  height: g.originHeight,
74
66
  editScaleX: g.editScaleX,
75
67
  editScaleY: g.editScaleY,
68
+ // Every draft rect must carry the element's rotation: the rotation wrapper
69
+ // renders rotate(overlayRect.angle), so an omitted angle straightens the
70
+ // chrome for the duration of the draft (the "straightens while moving" bug).
71
+ angle: g.actualRotation,
76
72
  });
77
73
  };
78
74
  const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => {
@@ -88,7 +84,11 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
88
84
  const startGesture = (
89
85
  kind: GestureKind,
90
86
  e: React.PointerEvent<HTMLElement>,
91
- options?: { selection?: DomEditSelection; rect?: OverlayRect | null },
87
+ options?: {
88
+ selection?: DomEditSelection;
89
+ rect?: OverlayRect | null;
90
+ resizeHandle?: ResizeHandle;
91
+ },
92
92
  ) => _startGesture(kind, e, opts, options);
93
93
 
94
94
  // fallow-ignore-next-line complexity
@@ -183,8 +183,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
183
183
  actualAngle: g.actualRotation,
184
184
  snap: e.shiftKey,
185
185
  });
186
- const draftViaGsap = applyRotationDraftViaGsap(sel.element, rotated.angle);
187
- if (!draftViaGsap) {
186
+ if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
188
187
  applyStudioRotationDraft(sel.element, rotated);
189
188
  }
190
189
  return;
@@ -192,7 +191,11 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
192
191
 
193
192
  if (g.kind === "drag") {
194
193
  const sc = g.snapContext;
195
- if (sc?.snapEnabled && sc.targets.length > 0) {
194
+ // Bypass edge-snapping for rotated elements — the snap targets and the
195
+ // snapped rect are axis-aligned, so snapping a rotated box's AABB shifts it
196
+ // unpredictably. Rotation ~0 keeps snapping exactly as before.
197
+ const dragRotated = Math.abs(g.actualRotation) >= ROTATED_SNAP_BYPASS_DEGREES;
198
+ if (!dragRotated && sc?.snapEnabled && sc.targets.length > 0) {
196
199
  // Snap the element's VISIBLE (crop-hugged) edges, not the full bounds.
197
200
  const movingRect = hugRectForElement(
198
201
  {
@@ -246,6 +249,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
246
249
  height: g.originHeight,
247
250
  editScaleX: g.editScaleX,
248
251
  editScaleY: g.editScaleY,
252
+ angle: g.actualRotation,
249
253
  });
250
254
  if (box) {
251
255
  box.style.left = `${nextBoxLeft}px`;
@@ -255,90 +259,49 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
255
259
  } else {
256
260
  if (!box) return;
257
261
 
258
- const sc = g.snapContext;
259
- if (sc?.snapEnabled && sc.targets.length > 0) {
260
- const movingRect = {
261
- left: g.originLeft,
262
- top: g.originTop,
263
- width: g.originWidth,
264
- height: g.originHeight,
265
- };
266
- const allTargets = sc.compositionTarget
267
- ? [...sc.targets, sc.compositionTarget]
268
- : sc.targets;
269
- const snap = resolveResizeSnapAdjustment({
270
- movingRect,
271
- proposedDx: dx,
272
- proposedDy: dy,
273
- targets: allTargets,
274
- gridEdges: sc.gridEdges ?? undefined,
275
- threshold: SNAP_THRESHOLD_PX,
276
- disabled: e.altKey,
277
- });
278
- dx = snap.dx;
279
- dy = snap.dy;
280
- opts.snapGuidesRef.current = { guides: snap.guides, spacingGuides: [] };
281
- }
282
-
283
- const nextSize = resolveDomEditResizeGesture({
284
- originWidth: g.originWidth,
285
- originHeight: g.originHeight,
286
- actualWidth: g.actualWidth,
287
- actualHeight: g.actualHeight,
288
- scaleX: g.editScaleX,
289
- scaleY: g.editScaleY,
290
- contentScaleX: g.contentScaleX,
291
- contentScaleY: g.contentScaleY,
292
- dx,
293
- dy,
294
- uniform: e.shiftKey,
262
+ // CENTER-ANCHORED size (CapCut model): the element scales proportionally
263
+ // about its CENTER the scale is the pointer's RADIAL distance from the
264
+ // element center now over its distance at gesture start. Rotation-invariant
265
+ // (a distance ignores the angle) and continuous, so all four corners behave
266
+ // identically and there is no per-axis projection or edge-snapping. Base size
267
+ // is the element-local px size at gesture start (actualWidth/Height,
268
+ // GSAP-scale-aware). Corner drag is ALWAYS proportional; there is no
269
+ // free-form stretch gesture. Edge-snapping is intentionally NOT applied:
270
+ // with center anchoring both edges move symmetrically, so the corner-anchored
271
+ // snap math no longer holds — CapCut does not edge-snap during scale either.
272
+ const nextSize = resolveCenterResizeSize({
273
+ baseWidth: g.actualWidth,
274
+ baseHeight: g.actualHeight,
275
+ pointer: { x: e.clientX, y: e.clientY },
276
+ pointerStart: { x: g.startX, y: g.startY },
277
+ centerStart: { x: g.centerX, y: g.centerY },
295
278
  });
296
279
  applyStudioBoxSizeDraft(sel.element, nextSize);
297
- // Pin the gesture anchor (top-left): with a live scale transform, the CSS
298
- // size change shifts the rendered box around the element center. Measure
299
- // the drift of the gesture-start corner and counter it via GSAP x/y —
300
- // accumulated onto the previous pin so the correction converges instead
301
- // of oscillating. The release-time position compensation re-measures the
302
- // drop, so the pin composes with the commit.
303
- const anchor = g.resizeAnchor;
304
- if (anchor) {
305
- const pinned = sel.element.getBoundingClientRect();
306
- const nextPinX = anchor.pinX + (anchor.anchorX - pinned.x);
307
- const nextPinY = anchor.pinY + (anchor.anchorY - pinned.y);
308
- if (
309
- setElementGsapPosition(
310
- sel.element,
311
- anchor.baseGsapX + nextPinX,
312
- anchor.baseGsapY + nextPinY,
313
- )
314
- ) {
315
- anchor.pinX = nextPinX;
316
- anchor.pinY = nextPinY;
317
- }
318
- }
319
- // Re-read BCR after applying dimensions. For elements with a GSAP
320
- // scale transform and centered transform-origin the visual top-left
321
- // drifts and the visual size diverges from the raw CSS size, so BCR
322
- // is the only accurate source for both.
280
+
323
281
  const overlayEl = opts.overlayRef.current;
324
282
  const iframe = opts.iframeRef.current;
325
- const refreshed = overlayEl && iframe ? toOverlayRect(overlayEl, iframe, sel.element) : null;
326
- const overlayLeft = refreshed ? refreshed.left : g.originLeft;
327
- const overlayTop = refreshed ? refreshed.top : g.originTop;
328
- const overlayWidth = refreshed ? refreshed.width : nextSize.overlayWidth;
329
- const overlayHeight = refreshed ? refreshed.height : nextSize.overlayHeight;
330
- box.style.left = `${overlayLeft}px`;
331
- box.style.top = `${overlayTop}px`;
332
- box.style.width = `${overlayWidth}px`;
333
- box.style.height = `${overlayHeight}px`;
334
- setDraftOverlayRect({
335
- left: overlayLeft,
336
- top: overlayTop,
337
- width: overlayWidth,
338
- height: overlayHeight,
339
- editScaleX: g.editScaleX,
340
- editScaleY: g.editScaleY,
283
+ const measureOrientedRect = () =>
284
+ overlayEl && iframe ? orientedOverlayRect(overlayEl, iframe, sel.element) : null;
285
+
286
+ const draftRect = resolveResizeDraftRect(
287
+ g,
288
+ sel.element,
289
+ overlayEl,
290
+ iframe,
291
+ measureOrientedRect,
292
+ );
293
+ logResizeMove({
294
+ pointer: { x: e.clientX, y: e.clientY },
295
+ nextSize,
296
+ anchor: g.lastResizeAnchor ?? null,
297
+ draftRect,
298
+ liveInlineStyle: sel.element.getAttribute("style"),
341
299
  });
300
+ box.style.left = `${draftRect.left}px`;
301
+ box.style.top = `${draftRect.top}px`;
302
+ box.style.width = `${draftRect.width}px`;
303
+ box.style.height = `${draftRect.height}px`;
304
+ setDraftOverlayRect(draftRect);
342
305
  }
343
306
  };
344
307
 
@@ -414,9 +377,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
414
377
  }
415
378
 
416
379
  if (g.kind === "resize" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
417
- restoreResizeAnchorPin(sel.element, g);
418
380
  restoreStudioBoxSize(sel.element, g.initialBoxSize);
419
- endStudioManualEditGesture(sel.element, g.manualEditDragToken);
381
+ if (g.pathOffsetMember) {
382
+ restoreManualOffsetDragMembers([g.pathOffsetMember]);
383
+ } else {
384
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
385
+ }
420
386
  if (box) {
421
387
  box.style.width = `${g.originWidth}px`;
422
388
  box.style.height = `${g.originHeight}px`;
@@ -444,8 +410,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
444
410
  restoreStudioRotation(sel.element, g.initialRotation);
445
411
  }
446
412
  };
447
- const rotationChanged = hasDomEditRotationChanged(g.actualRotation, finalRotation.angle);
448
- if (!rotationChanged) {
413
+ if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
449
414
  restoreRotation();
450
415
  endStudioManualEditGesture(sel.element, g.manualEditDragToken);
451
416
  return;
@@ -464,10 +429,13 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
464
429
  )
465
430
  restoreRotation();
466
431
  })
467
- .finally(() => {
468
- endStudioManualEditGesture(sel.element, g.manualEditDragToken);
469
- });
432
+ .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
470
433
  } else if (g.kind === "drag") {
434
+ // A moved drag (taps returned earlier) must not let the release click
435
+ // re-select whatever now sits under the pointer — dropping over a
436
+ // higher-z element should keep the dragged element selected, not select
437
+ // the drop target. Mirrors the resize branch below.
438
+ opts.suppressNextBoxClickRef.current = true;
471
439
  const dx = g.lastSnappedDx ?? e.clientX - g.startX;
472
440
  const dy = g.lastSnappedDy ?? e.clientY - g.startY;
473
441
  if (!g.pathOffsetMember) {
@@ -483,6 +451,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
483
451
  height: g.originHeight,
484
452
  editScaleX: g.editScaleX,
485
453
  editScaleY: g.editScaleY,
454
+ angle: g.actualRotation,
486
455
  });
487
456
  if (box) {
488
457
  box.style.left = `${nextBoxLeft}px`;
@@ -505,18 +474,45 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
505
474
  opts.suppressNextBoxClickRef.current = true;
506
475
  const finalSize = readStudioBoxSize(sel.element);
507
476
  applyStudioBoxSize(sel.element, finalSize);
508
- void Promise.resolve(opts.onBoxSizeCommitRef.current(sel, finalSize))
477
+ // Anchored corner resize (NW/NE/SW) also moved the element to keep the
478
+ // center planted. Land the size AND the anchor offset in a SINGLE
479
+ // box-size commit (one persist, one undo entry). The prior two-commit
480
+ // sequence re-stamped the element from source after the size-only persist
481
+ // but before the offset persist landed — that one frame (new size, old
482
+ // offset) was the release "jump". SE has no anchor member → size only.
483
+ const member = g.pathOffsetMember;
484
+ const anchor = g.lastResizeAnchor;
485
+ const finalOffset =
486
+ member && anchor && (anchor.dx !== 0 || anchor.dy !== 0)
487
+ ? applyManualOffsetDragCommit(member, anchor.dx, anchor.dy)
488
+ : null;
489
+ logResize("release", {
490
+ finalSize,
491
+ anchor: anchor ?? null,
492
+ finalOffset: finalOffset ?? null,
493
+ hasMember: !!member,
494
+ inlineStyle: sel.element.getAttribute("style"),
495
+ });
496
+ const restore = () => {
497
+ if (
498
+ !g.manualEditDragToken ||
499
+ !isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
500
+ )
501
+ return;
502
+ restoreStudioBoxSize(sel.element, g.initialBoxSize);
503
+ if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
504
+ };
505
+ void Promise.resolve(
506
+ opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
507
+ )
509
508
  .catch((error) => {
510
509
  console.error("resize commit failed", error);
511
- if (
512
- g.manualEditDragToken &&
513
- isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
514
- ) {
515
- restoreResizeAnchorPin(sel.element, g);
516
- restoreStudioBoxSize(sel.element, g.initialBoxSize);
517
- }
518
510
  })
519
- .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
511
+ .finally(() => {
512
+ if (member) endManualOffsetDragMembers([member]);
513
+ else endStudioManualEditGesture(sel.element, g.manualEditDragToken);
514
+ });
515
+ logResizeSettle(sel.element, "post-release");
520
516
  }
521
517
  };
522
518
 
@@ -534,9 +530,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
534
530
  restoreGestureOverlayRect(g);
535
531
  }
536
532
  if (g?.mode === "box-size" && sel) {
537
- restoreResizeAnchorPin(sel.element, g);
538
533
  restoreStudioBoxSize(sel.element, g.initialBoxSize);
539
- endStudioManualEditGesture(sel.element, g.manualEditDragToken);
534
+ if (g.pathOffsetMember) {
535
+ restoreManualOffsetDragMembers([g.pathOffsetMember]);
536
+ } else {
537
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
538
+ }
540
539
  restoreGestureOverlayRect(g);
541
540
  }
542
541
  if (g?.mode === "rotation" && sel) {
@@ -13,6 +13,7 @@ import {
13
13
  groupOverlayItemsEqual,
14
14
  isElementVisibleForOverlay,
15
15
  groupAwareOverlayRect,
16
+ orientedGroupAwareOverlayRect,
16
17
  rectsEqual,
17
18
  resolveElementForOverlay,
18
19
  selectionCacheKey,
@@ -157,7 +158,13 @@ export function useDomEditOverlayRects({
157
158
  // backgroundless full-bleed scene above a subcomposition), which would wrongly
158
159
  // hide the selection box. Occlusion stays for hover, where a false hide is cheap.
159
160
  if (el && isElementVisibleForOverlay(el)) {
160
- const nextRect = groupAwareOverlayRect(overlayEl, iframe, el);
161
+ // Groups render as an AABB union of their members (a group OBB is out of
162
+ // scope); a single element renders as an oriented box that co-rotates
163
+ // with its transform. orientedOverlayRect gates on rotation internally
164
+ // (a cheap per-call check) and only pays for the full corner-transform
165
+ // measurement when the element is actually rotated — this RAF loop runs
166
+ // every frame for any single selection, so that gate matters here most.
167
+ const nextRect = orientedGroupAwareOverlayRect(overlayEl, iframe, el);
161
168
  setOverlayRect(nextRect);
162
169
  const descendants = el.querySelectorAll("*");
163
170
  if (descendants.length > 0 && descendants.length <= 60) {
@@ -242,7 +249,7 @@ export function useDomEditOverlayRects({
242
249
  return;
243
250
  }
244
251
 
245
- setHoverRect(groupAwareOverlayRect(overlayEl, iframe, hoverEl));
252
+ setHoverRect(orientedGroupAwareOverlayRect(overlayEl, iframe, hoverEl));
246
253
  };
247
254
 
248
255
  frame = requestAnimationFrame(update);
@@ -0,0 +1,147 @@
1
+ /**
2
+ * CapCut-style asset preview overlay rendered inside PreviewPane.
3
+ *
4
+ * Shown when the user clicks an asset card that has NOT yet been added to the
5
+ * timeline. Displays the media (image / video / audio) without modifying the
6
+ * composition — no undo entry, no file mutation.
7
+ *
8
+ * Dismiss: X button, Escape key, or click on the scrim.
9
+ * Switching to another not-added asset replaces the current preview.
10
+ */
11
+ import { useEffect, useCallback } from "react";
12
+ import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
13
+ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
14
+ import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
15
+
16
+ function basename(path: string): string {
17
+ return path.split("/").pop() ?? path;
18
+ }
19
+
20
+ type AssetKind = "image" | "video" | "audio";
21
+
22
+ function resolveAssetKind(path: string): AssetKind {
23
+ if (VIDEO_EXT.test(path)) return "video";
24
+ if (IMAGE_EXT.test(path)) return "image";
25
+ return "audio";
26
+ }
27
+
28
+ /** The media element for a previewed asset, chosen by kind. */
29
+ function AssetPreviewMedia({
30
+ kind,
31
+ serveUrl,
32
+ name,
33
+ }: {
34
+ kind: AssetKind;
35
+ serveUrl: string;
36
+ name: string;
37
+ }) {
38
+ if (kind === "image") {
39
+ return (
40
+ <img
41
+ src={serveUrl}
42
+ alt={name}
43
+ className="max-w-full max-h-[70vh] rounded-md object-contain shadow-2xl"
44
+ />
45
+ );
46
+ }
47
+ if (kind === "video") {
48
+ return (
49
+ <video
50
+ src={serveUrl}
51
+ controls
52
+ autoPlay
53
+ muted
54
+ playsInline
55
+ className="max-w-full max-h-[70vh] rounded-md shadow-2xl"
56
+ />
57
+ );
58
+ }
59
+ return (
60
+ <div className="flex flex-col items-center gap-4 px-6 py-8 rounded-xl bg-neutral-900 border border-neutral-800">
61
+ <svg
62
+ width="40"
63
+ height="40"
64
+ viewBox="0 0 24 24"
65
+ fill="none"
66
+ stroke="currentColor"
67
+ strokeWidth="1.5"
68
+ className="text-neutral-500"
69
+ >
70
+ <path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
71
+ <circle cx="6" cy="18" r="3" />
72
+ <circle cx="18" cy="16" r="3" />
73
+ </svg>
74
+ <audio src={serveUrl} controls className="w-64" />
75
+ </div>
76
+ );
77
+ }
78
+
79
+ export function AssetPreviewOverlay() {
80
+ const previewAsset = useAssetPreviewStore((s) => s.previewAsset);
81
+ const previewProjectId = useAssetPreviewStore((s) => s.previewProjectId);
82
+ const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
83
+
84
+ const handleKeyDown = useCallback(
85
+ (e: KeyboardEvent) => {
86
+ if (e.key === "Escape") clearPreviewAsset();
87
+ },
88
+ [clearPreviewAsset],
89
+ );
90
+
91
+ useEffect(() => {
92
+ if (!previewAsset) return;
93
+ window.addEventListener("keydown", handleKeyDown);
94
+ return () => window.removeEventListener("keydown", handleKeyDown);
95
+ }, [previewAsset, handleKeyDown]);
96
+
97
+ if (!previewAsset || !previewProjectId) return null;
98
+
99
+ const serveUrl = resolveMediaPreviewUrl(previewAsset, previewProjectId);
100
+ const name = basename(previewAsset);
101
+
102
+ return (
103
+ <div
104
+ className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/80"
105
+ onClick={clearPreviewAsset}
106
+ role="dialog"
107
+ aria-label={`Preview: ${name}`}
108
+ aria-modal="true"
109
+ >
110
+ {/* Close button */}
111
+ <button
112
+ className="absolute top-3 right-3 w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
113
+ onClick={(e) => {
114
+ e.stopPropagation();
115
+ clearPreviewAsset();
116
+ }}
117
+ aria-label="Close preview"
118
+ >
119
+ <svg
120
+ width="12"
121
+ height="12"
122
+ viewBox="0 0 24 24"
123
+ stroke="currentColor"
124
+ strokeWidth="2.5"
125
+ fill="none"
126
+ strokeLinecap="round"
127
+ >
128
+ <line x1="18" y1="6" x2="6" y2="18" />
129
+ <line x1="6" y1="6" x2="18" y2="18" />
130
+ </svg>
131
+ </button>
132
+
133
+ {/* Media */}
134
+ <div
135
+ className="flex flex-col items-center gap-3 max-w-[90%] max-h-[85%]"
136
+ onClick={(e) => e.stopPropagation()}
137
+ >
138
+ <AssetPreviewMedia kind={resolveAssetKind(previewAsset)} serveUrl={serveUrl} name={name} />
139
+
140
+ {/* Filename label */}
141
+ <span className="text-[12px] text-neutral-400 truncate max-w-full px-2 text-center">
142
+ {name}
143
+ </span>
144
+ </div>
145
+ </div>
146
+ );
147
+ }
@@ -0,0 +1,144 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { shouldDisableTimelineWhileCompositionLoading, NLEProvider } from "./NLEContext";
6
+ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
7
+ import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
8
+
9
+ installReactActEnvironment();
10
+
11
+ // Render NLEProvider into a fresh detached host and settle the mount effect.
12
+ async function mountNleProvider(props: {
13
+ projectId: string;
14
+ refreshKey?: number;
15
+ }): Promise<{ host: HTMLDivElement; root: Root }> {
16
+ const host = document.createElement("div");
17
+ document.body.append(host);
18
+ const root = createRoot(host);
19
+ await renderNleProvider(root, props);
20
+ return { host, root };
21
+ }
22
+
23
+ // Re-render NLEProvider into an existing root and settle effects.
24
+ async function renderNleProvider(
25
+ root: Root,
26
+ props: {
27
+ projectId: string;
28
+ refreshKey?: number;
29
+ onCompIdToSrcChange?: (map: Map<string, string>) => void;
30
+ },
31
+ ): Promise<void> {
32
+ await act(async () => {
33
+ root.render(React.createElement(NLEProvider, props, React.createElement("div")));
34
+ await Promise.resolve();
35
+ });
36
+ }
37
+
38
+ describe("timeline loading disable state", () => {
39
+ it("disables the timeline while the composition loading overlay is visible", () => {
40
+ expect(shouldDisableTimelineWhileCompositionLoading(true)).toBe(true);
41
+ });
42
+
43
+ it("reenables the timeline after composition loading finishes", () => {
44
+ expect(shouldDisableTimelineWhileCompositionLoading(false)).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe("NLEProvider — asset preview scoping", () => {
49
+ beforeEach(() => {
50
+ // No project API in this unit test — stub fetch so the compIdToSrc mount
51
+ // effect's request rejects quietly instead of hitting the network.
52
+ vi.stubGlobal(
53
+ "fetch",
54
+ vi.fn(() => Promise.reject(new Error("no network in tests"))),
55
+ );
56
+ });
57
+
58
+ afterEach(() => {
59
+ vi.unstubAllGlobals();
60
+ useAssetPreviewStore.getState().clearPreviewAsset();
61
+ });
62
+
63
+ it("clears a stale cross-project asset preview when the active project changes", async () => {
64
+ const { host, root } = await mountNleProvider({ projectId: "project-a" });
65
+
66
+ useAssetPreviewStore.getState().setPreviewAsset("assets/photo.png", "project-a");
67
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/photo.png");
68
+
69
+ await renderNleProvider(root, { projectId: "project-b" });
70
+
71
+ // The overlay stays mounted across the project switch (EditorShell isn't
72
+ // keyed by projectId) — the store itself must be the thing that clears.
73
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
74
+ expect(useAssetPreviewStore.getState().previewProjectId).toBeNull();
75
+
76
+ act(() => root.unmount());
77
+ host.remove();
78
+ });
79
+
80
+ it("does not clear the preview on a re-render that keeps the same projectId", async () => {
81
+ const { host, root } = await mountNleProvider({ projectId: "project-a" });
82
+
83
+ useAssetPreviewStore.getState().setPreviewAsset("assets/photo.png", "project-a");
84
+
85
+ await renderNleProvider(root, { projectId: "project-a", refreshKey: 1 });
86
+
87
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/photo.png");
88
+
89
+ act(() => root.unmount());
90
+ host.remove();
91
+ });
92
+ });
93
+
94
+ describe("NLEProvider — composition source-map scoping", () => {
95
+ afterEach(() => vi.unstubAllGlobals());
96
+
97
+ it("clears the old map and ignores a late project-A response after switching to B", async () => {
98
+ let resolveA!: (response: Response) => void;
99
+ const fetchMock = vi.fn((url: string) => {
100
+ if (url.includes("project-a")) {
101
+ return new Promise<Response>((resolve) => {
102
+ resolveA = resolve;
103
+ });
104
+ }
105
+ return Promise.resolve({
106
+ ok: true,
107
+ json: async () => ({
108
+ content: '<div data-composition-id="b-comp" data-composition-src="compositions/b.html">',
109
+ }),
110
+ } as Response);
111
+ });
112
+ vi.stubGlobal("fetch", fetchMock);
113
+ const observed: Array<Map<string, string>> = [];
114
+ const onChange = (map: Map<string, string>) => observed.push(new Map(map));
115
+ const { host, root } = await mountNleProvider({
116
+ projectId: "project-a",
117
+ onCompIdToSrcChange: onChange,
118
+ });
119
+
120
+ await renderNleProvider(root, {
121
+ projectId: "project-b",
122
+ onCompIdToSrcChange: onChange,
123
+ });
124
+ await act(async () => Promise.resolve());
125
+
126
+ expect(observed.filter((map) => map.size === 0)).toHaveLength(2);
127
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b/files/index.html", {
128
+ signal: expect.any(AbortSignal),
129
+ });
130
+ expect(observed.at(-1)).toEqual(new Map([["b-comp", "compositions/b.html"]]));
131
+
132
+ resolveA({
133
+ ok: true,
134
+ json: async () => ({
135
+ content: '<div data-composition-id="a-comp" data-composition-src="compositions/a.html">',
136
+ }),
137
+ } as Response);
138
+ await act(async () => Promise.resolve());
139
+ expect(observed.at(-1)).toEqual(new Map([["b-comp", "compositions/b.html"]]));
140
+
141
+ act(() => root.unmount());
142
+ host.remove();
143
+ });
144
+ });