@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
@@ -0,0 +1,328 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useCallback,
6
+ useRef,
7
+ useEffect,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { useTimelinePlayer, usePlayerStore } from "../../player";
11
+ import type { TimelineElement } from "../../player";
12
+ import type { CompositionLevel } from "./CompositionBreadcrumb";
13
+ import { useCompositionStack } from "./useCompositionStack";
14
+ import { MIN_TIMELINE_H, MIN_PREVIEW_H } from "./TimelineResizeDivider";
15
+ import { setCompositionSourceMap } from "../editor/domEditingDom";
16
+ import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
17
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
18
+ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
19
+
20
+ // Timeline gets a generous default height so the preview isn't oversized and the
21
+ // tracks have room to breathe (CapCut-style). Users can still drag the divider.
22
+ const DEFAULT_TIMELINE_H = 340;
23
+
24
+ export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
25
+ return compositionLoading;
26
+ }
27
+
28
+ export interface NLEContextValue {
29
+ projectId: string;
30
+ // player (from useTimelinePlayer — single instance for the whole shell)
31
+ iframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
32
+ togglePlay: () => void;
33
+ seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
34
+ refreshPlayer: () => void;
35
+ onIframeLoad: () => void;
36
+ // composition stack (from useCompositionStack)
37
+ compositionStack: CompositionLevel[];
38
+ updateCompositionStack: React.Dispatch<React.SetStateAction<CompositionLevel[]>>;
39
+ handleNavigateComposition: (index: number) => void;
40
+ handleDrillDown: (element: TimelineElement) => void;
41
+ compIdToSrc: Map<string, string>;
42
+ // layout state
43
+ timelineH: number;
44
+ setTimelineH: React.Dispatch<React.SetStateAction<number>>;
45
+ persistTimelineH: (height: number) => void;
46
+ containerRef: React.RefObject<HTMLDivElement | null>;
47
+ // composition loading
48
+ compositionLoading: boolean;
49
+ setCompositionLoading: (loading: boolean) => void;
50
+ timelineDisabled: boolean;
51
+ hasLoadedOnceRef: React.MutableRefObject<boolean>;
52
+ // preview composition size (for preview block drop)
53
+ previewCompositionSize: { width: number; height: number } | null;
54
+ setPreviewCompositionSize: (size: { width: number; height: number } | null) => void;
55
+ }
56
+
57
+ const NLEContext = createContext<NLEContextValue | null>(null);
58
+
59
+ export function useNLEContext(): NLEContextValue {
60
+ const ctx = useContext(NLEContext);
61
+ if (!ctx) throw new Error("useNLEContext must be used within an NLEProvider");
62
+ return ctx;
63
+ }
64
+
65
+ export interface NLEProviderProps {
66
+ projectId: string;
67
+ refreshKey?: number;
68
+ activeCompositionPath?: string | null;
69
+ onIframeRef?: (iframe: HTMLIFrameElement | null) => void;
70
+ onCompositionChange?: (compositionPath: string | null) => void;
71
+ onCompIdToSrcChange?: (map: Map<string, string>) => void;
72
+ onCompositionLoadingChange?: (loading: boolean) => void;
73
+ children: ReactNode;
74
+ }
75
+
76
+ export function NLEProvider({
77
+ projectId,
78
+ refreshKey,
79
+ activeCompositionPath,
80
+ onIframeRef,
81
+ onCompositionChange,
82
+ onCompIdToSrcChange,
83
+ onCompositionLoadingChange,
84
+ children,
85
+ }: NLEProviderProps) {
86
+ const {
87
+ iframeRef,
88
+ togglePlay,
89
+ seek,
90
+ onIframeLoad: baseOnIframeLoad,
91
+ refreshPlayer,
92
+ } = useTimelinePlayer();
93
+
94
+ // Reset timeline state when the project changes. Done in an effect, not during
95
+ // render: reset() updates the player store, and updating another store/component
96
+ // mid-render triggers React's "Cannot update a component while rendering a
97
+ // different component" warning. The effect runs right after commit, so the new
98
+ // project's first frame may briefly show prior timeline state before it clears.
99
+ //
100
+ // Also clears the asset preview overlay store: it is project-scoped (see its
101
+ // doc-comment) but nothing else clears it on project change — EditorShell isn't
102
+ // keyed by projectId and the overlay stays mounted, so a preview opened in one
103
+ // project would otherwise keep rendering (and re-fetching from) the old project
104
+ // after switching.
105
+ useEffect(() => {
106
+ usePlayerStore.getState().reset();
107
+ useAssetPreviewStore.getState().clearPreviewAsset();
108
+ }, [projectId]);
109
+
110
+ // Authored composition size measured from the loaded preview — drives drop
111
+ // coordinate mapping so blocks land where the user pointed on any comp size.
112
+ const [previewCompositionSize, setPreviewCompositionSize] = useState<{
113
+ width: number;
114
+ height: number;
115
+ } | null>(null);
116
+
117
+ // Lightweight reload: change iframe src instead of destroying the Player.
118
+ const prevRefreshKeyRef = useRef(refreshKey);
119
+ useEffect(() => {
120
+ if (refreshKey === prevRefreshKeyRef.current) return;
121
+ prevRefreshKeyRef.current = refreshKey;
122
+ refreshPlayer();
123
+ }, [refreshKey, refreshPlayer]);
124
+
125
+ const onIframeLoad = useCallback(() => {
126
+ baseOnIframeLoad();
127
+ // Pre-load + register MotionPathPlugin once so adding a motion path in the
128
+ // studio doesn't take the async plugin-load flash path on the first soft
129
+ // reload (the comp may not ship the plugin until it actually uses one).
130
+ ensureMotionPathPluginLoaded(iframeRef.current);
131
+ onIframeRef?.(iframeRef.current);
132
+ }, [baseOnIframeLoad, iframeRef, onIframeRef]);
133
+
134
+ const {
135
+ compositionStack,
136
+ updateCompositionStack,
137
+ handleNavigateComposition,
138
+ handleDrillDown: drillDown,
139
+ compIdToSrc,
140
+ setCompIdToSrc,
141
+ } = useCompositionStack({
142
+ projectId,
143
+ activeCompositionPath,
144
+ onCompositionChange,
145
+ });
146
+
147
+ // Wrap handleDrillDown to also scan the iframe DOM for data-composition-src
148
+ const iframeRef_ = iframeRef;
149
+ const handleDrillDown = useCallback(
150
+ (element: TimelineElement) => {
151
+ if (!element.compositionSrc) return;
152
+ usePlayerStore.getState().setSelectedElementId(null);
153
+ // Check compIdToSrc map first; then scan iframe DOM; then fall through to drillDown
154
+ const compId = element.id;
155
+ let resolvedPath = compIdToSrc.get(compId);
156
+ if (!resolvedPath) {
157
+ try {
158
+ const doc = iframeRef_.current?.contentDocument;
159
+ if (doc) {
160
+ const host = doc.querySelector(
161
+ `[data-composition-id="${CSS.escape(compId)}"][data-composition-src]`,
162
+ );
163
+ if (host) {
164
+ resolvedPath = host.getAttribute("data-composition-src") || undefined;
165
+ }
166
+ }
167
+ } catch {
168
+ /* cross-origin */
169
+ }
170
+ }
171
+ // Delegate with the resolved compositionSrc (may be same as original)
172
+ drillDown({
173
+ id: compId,
174
+ compositionSrc: resolvedPath ?? element.compositionSrc,
175
+ });
176
+ },
177
+ [compIdToSrc, drillDown, iframeRef_],
178
+ );
179
+
180
+ // Composition ID → file path map from raw index.html
181
+ const compIdToSrcRef = useRef(compIdToSrc);
182
+ compIdToSrcRef.current = compIdToSrc;
183
+ const onCompIdToSrcChangeRef = useRef(onCompIdToSrcChange);
184
+ onCompIdToSrcChangeRef.current = onCompIdToSrcChange;
185
+
186
+ useEffect(() => {
187
+ const controller = new AbortController();
188
+ let current = true;
189
+ const emptyMap = new Map<string, string>();
190
+ setCompIdToSrc(emptyMap);
191
+ setCompositionSourceMap(emptyMap);
192
+ onCompIdToSrcChangeRef.current?.(emptyMap);
193
+
194
+ fetch(`/api/projects/${projectId}/files/index.html`, {
195
+ signal: controller.signal,
196
+ })
197
+ .then((r) => {
198
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
199
+ return r.json();
200
+ })
201
+ .then((data: { content?: string }) => {
202
+ if (!current) return;
203
+ const html = data.content || "";
204
+ const map = new Map<string, string>();
205
+ const re =
206
+ /data-composition-id=["']([^"']+)["'][^>]*data-composition-src=["']([^"']+)["']|data-composition-src=["']([^"']+)["'][^>]*data-composition-id=["']([^"']+)["']/g;
207
+ let match;
208
+ while ((match = re.exec(html)) !== null) {
209
+ const id = match[1] || match[4];
210
+ const src = match[2] || match[3];
211
+ if (id && src) map.set(id, src);
212
+ }
213
+ setCompIdToSrc(map);
214
+ // Let DOM source-resolution recover a subcomposition element's source file
215
+ // (the runtime drops the linkage when inlining — see getSourceFileForElement).
216
+ setCompositionSourceMap(map);
217
+ onCompIdToSrcChangeRef.current?.(map);
218
+ })
219
+ .catch((err: unknown) => {
220
+ if (!current || controller.signal.aborted) return;
221
+ // Non-fatal: drill-down still works via the iframe DOM scan; without
222
+ // the map only source-file resolution for sub-comps degrades.
223
+ console.warn("[studio] Couldn't load composition source map from index.html:", err);
224
+ });
225
+ return () => {
226
+ current = false;
227
+ controller.abort();
228
+ };
229
+ }, [projectId, setCompIdToSrc]);
230
+
231
+ // Patch elements with compositionSrc whenever elements or compIdToSrc change.
232
+ // eslint-disable-next-line no-restricted-syntax
233
+ useEffect(() => {
234
+ if (compIdToSrc.size === 0) return;
235
+ const patchElements = (elements: TimelineElement[]): TimelineElement[] | null => {
236
+ const map = compIdToSrcRef.current;
237
+ if (map.size === 0) return null;
238
+ let patched = false;
239
+ const updated = elements.map((el) => {
240
+ if (el.compositionSrc) return el;
241
+ const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, ""));
242
+ if (src) {
243
+ patched = true;
244
+ return { ...el, compositionSrc: src };
245
+ }
246
+ return el;
247
+ });
248
+ return patched ? updated : null;
249
+ };
250
+ const patched = patchElements(usePlayerStore.getState().elements);
251
+ if (patched) usePlayerStore.getState().setElements(patched);
252
+ let patching = false;
253
+ return usePlayerStore.subscribe((state, prev) => {
254
+ if (patching) return;
255
+ if (state.elements === prev.elements || state.elements.length === 0) return;
256
+ if (state.elements.every((el) => el.compositionSrc)) return;
257
+ patching = true;
258
+ const result = patchElements(state.elements);
259
+ if (result) state.setElements(result);
260
+ patching = false;
261
+ });
262
+ }, [compIdToSrc]);
263
+
264
+ // Resizable timeline height — persisted alongside zoom/pan so the user's
265
+ // workspace layout survives reloads.
266
+ const [timelineH, setTimelineH] = useState(() => {
267
+ const stored = readStudioUiPreferences().timelineHeight;
268
+ return stored !== undefined && stored >= MIN_TIMELINE_H ? stored : DEFAULT_TIMELINE_H;
269
+ });
270
+ const persistTimelineH = useCallback((height: number) => {
271
+ writeStudioUiPreferences({ timelineHeight: Math.round(height) });
272
+ }, []);
273
+ const containerRef = useRef<HTMLDivElement>(null);
274
+ // A height persisted on a tall window can exceed this window's container and
275
+ // collapse the flex-1 preview to 0px — clamp once the container is measurable
276
+ // (the drag/keyboard paths already clamp; the restore path must too).
277
+ useEffect(() => {
278
+ const containerH = containerRef.current?.getBoundingClientRect().height;
279
+ if (!containerH) return;
280
+ const max = containerH - MIN_PREVIEW_H;
281
+ setTimelineH((prev) => (prev > max ? Math.max(MIN_TIMELINE_H, max) : prev));
282
+ }, []);
283
+
284
+ const hasLoadedOnceRef = useRef(false);
285
+ const [compositionLoading, setCompositionLoadingRaw] = useState(true);
286
+ const setCompositionLoading = useCallback((loading: boolean) => {
287
+ if (!loading) hasLoadedOnceRef.current = true;
288
+ if (loading && hasLoadedOnceRef.current) return;
289
+ setCompositionLoadingRaw(loading);
290
+ }, []);
291
+ const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
292
+
293
+ useEffect(() => {
294
+ onCompositionLoadingChange?.(compositionLoading);
295
+ }, [compositionLoading, onCompositionLoadingChange]);
296
+
297
+ const onIframeRefStable = useRef(onIframeRef);
298
+ onIframeRefStable.current = onIframeRef;
299
+ useEffect(() => {
300
+ onIframeRefStable.current?.(iframeRef.current);
301
+ }, [compositionStack.length, refreshKey, iframeRef]);
302
+
303
+ const value: NLEContextValue = {
304
+ projectId,
305
+ iframeRef,
306
+ togglePlay,
307
+ seek,
308
+ refreshPlayer,
309
+ onIframeLoad,
310
+ compositionStack,
311
+ updateCompositionStack,
312
+ handleNavigateComposition,
313
+ handleDrillDown,
314
+ compIdToSrc,
315
+ timelineH,
316
+ setTimelineH,
317
+ persistTimelineH,
318
+ containerRef,
319
+ compositionLoading,
320
+ setCompositionLoading,
321
+ timelineDisabled,
322
+ hasLoadedOnceRef,
323
+ previewCompositionSize,
324
+ setPreviewCompositionSize,
325
+ };
326
+
327
+ return <NLEContext.Provider value={value}>{children}</NLEContext.Provider>;
328
+ }
@@ -459,7 +459,7 @@ export const NLEPreview = memo(function NLEPreview({
459
459
  <div className="flex flex-col h-full min-h-0">
460
460
  <div
461
461
  ref={viewportRef}
462
- className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40 bg-neutral-700"
462
+ className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40 bg-neutral-950"
463
463
  tabIndex={0}
464
464
  aria-label="Composition preview"
465
465
  >
@@ -0,0 +1,240 @@
1
+ import { useState } from "react";
2
+ import { CaptionOverlay } from "../../captions/components/CaptionOverlay";
3
+ import { DomEditOverlay } from "../editor/DomEditOverlay";
4
+ import { MotionPathOverlay } from "../editor/MotionPathOverlay";
5
+ import { SnapToolbar } from "../editor/SnapToolbar";
6
+ import { useCompositionDimensions } from "../../hooks/useCompositionDimensions";
7
+ import {
8
+ STUDIO_INSPECTOR_PANELS_ENABLED,
9
+ STUDIO_KEYFRAMES_ENABLED,
10
+ STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
11
+ STUDIO_PREVIEW_SELECTION_ENABLED,
12
+ } from "../editor/manualEditingAvailability";
13
+ import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
14
+ import {
15
+ useDomEditActionsContext,
16
+ useDomEditSelectionContext,
17
+ } from "../../contexts/DomEditContext";
18
+ import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
19
+ import { readHfId, type DomEditSelection } from "../editor/domEditing";
20
+ import { buildStableSelector } from "../editor/domEditingDom";
21
+ import type { BlockPreviewInfo } from "../sidebar/BlocksTab";
22
+ import type { GestureRecordingState } from "../editor/GestureRecordControl";
23
+ import type { ReactNode } from "react";
24
+
25
+ export interface PreviewOverlaysProps {
26
+ shouldShowSelectedDomBounds: boolean;
27
+ blockPreview?: BlockPreviewInfo | null;
28
+ isGestureRecording?: boolean;
29
+ recordingState?: GestureRecordingState;
30
+ onToggleRecording?: () => void;
31
+ gestureOverlay?: ReactNode;
32
+ }
33
+
34
+ type ZIndexReorderEntry = {
35
+ element: HTMLElement;
36
+ zIndex: number;
37
+ id?: string;
38
+ selector?: string;
39
+ selectorIndex?: number;
40
+ sourceFile: string;
41
+ };
42
+
43
+ /** Can this element be robustly re-targeted for a persisted z change? */
44
+ function canTargetZIndexElement(
45
+ element: HTMLElement,
46
+ id: string | undefined,
47
+ selector: string | undefined,
48
+ ): boolean {
49
+ return Boolean(id || selector || readHfId(element));
50
+ }
51
+
52
+ /** The selected element carries its full selection identity. */
53
+ function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReorderEntry {
54
+ return {
55
+ element: sel.element,
56
+ zIndex,
57
+ id: sel.id ?? undefined,
58
+ selector: sel.selector,
59
+ selectorIndex: sel.selectorIndex,
60
+ sourceFile: sel.sourceFile,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Sibling elements are raw iframe DOM nodes with no selection object: derive a
66
+ * PatchTarget from the node itself (siblings live in the same document, so they
67
+ * share the selection's sourceFile). Null when it cannot be robustly targeted
68
+ * (no id and no selector) — its z stays live-only.
69
+ */
70
+ function siblingZIndexEntry(
71
+ element: HTMLElement,
72
+ zIndex: number,
73
+ sourceFile: string,
74
+ ): ZIndexReorderEntry | null {
75
+ const id = element.id || undefined;
76
+ const selector = buildStableSelector(element);
77
+ if (!canTargetZIndexElement(element, id, selector)) return null;
78
+ return { element, zIndex, id, selector, selectorIndex: undefined, sourceFile };
79
+ }
80
+
81
+ /** Short human-readable label for a dropped sibling, for the console warning below. */
82
+ function describeZIndexElement(element: HTMLElement): string {
83
+ if (element.id) return `#${element.id}`;
84
+ const firstClass = element.classList.item(0);
85
+ return firstClass
86
+ ? `${element.tagName.toLowerCase()}.${firstClass}`
87
+ : element.tagName.toLowerCase();
88
+ }
89
+
90
+ // Resolve z-index patches into commit entries; a sibling with no stable
91
+ // id/selector can't be written to source, so it is collected as a label for the
92
+ // revert-on-reload warning instead.
93
+ function resolveZIndexEntries(
94
+ sel: DomEditSelection,
95
+ patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
96
+ ): { entries: ZIndexReorderEntry[]; droppedLabels: string[] } {
97
+ const entries: ZIndexReorderEntry[] = [];
98
+ const droppedLabels: string[] = [];
99
+ for (const patch of patches) {
100
+ if (patch.element === sel.element) {
101
+ entries.push(selectedZIndexEntry(sel, patch.zIndex));
102
+ continue;
103
+ }
104
+ const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
105
+ if (entry) entries.push(entry);
106
+ else droppedLabels.push(describeZIndexElement(patch.element));
107
+ }
108
+ return { entries, droppedLabels };
109
+ }
110
+
111
+ // fallow-ignore-next-line complexity
112
+ export function PreviewOverlays({
113
+ shouldShowSelectedDomBounds,
114
+ blockPreview,
115
+ isGestureRecording,
116
+ recordingState,
117
+ onToggleRecording,
118
+ gestureOverlay,
119
+ }: PreviewOverlaysProps) {
120
+ const { activeCompPath, previewIframeRef } = useStudioShellContext();
121
+ const { captionEditMode, compositionLoading, isPlaying } = useStudioPlaybackContext();
122
+ const compositionDimensions = useCompositionDimensions();
123
+
124
+ const { domEditHoverSelection, domEditSelection, domEditGroupSelections } =
125
+ useDomEditSelectionContext();
126
+ const {
127
+ handlePreviewCanvasMouseDown,
128
+ handlePreviewCanvasPointerMove,
129
+ handlePreviewCanvasPointerLeave,
130
+ applyDomSelection,
131
+ handleBlockedDomMove,
132
+ handleDomManualDragStart,
133
+ handleDomPathOffsetCommit,
134
+ handleDomGroupPathOffsetCommit,
135
+ handleDomBoxSizeCommit,
136
+ handleDomRotationCommit,
137
+ handleDomStyleCommit,
138
+ applyMarqueeSelection,
139
+ handleDomEditElementDelete,
140
+ handleDomZIndexReorderCommit,
141
+ } = useDomEditActionsContext();
142
+
143
+ // fallow-ignore-next-line complexity
144
+ const [snapPrefs, setSnapPrefs] = useState(() => {
145
+ const p = readStudioUiPreferences();
146
+ return {
147
+ snapEnabled: p.snapEnabled ?? true,
148
+ gridVisible: p.gridVisible ?? false,
149
+ gridSpacing: p.gridSpacing ?? 50,
150
+ snapToGrid: p.snapToGrid ?? false,
151
+ };
152
+ });
153
+
154
+ if (blockPreview) {
155
+ return (
156
+ <div className="absolute inset-0 z-30 bg-black pointer-events-none">
157
+ {blockPreview.videoUrl ? (
158
+ <video
159
+ src={blockPreview.videoUrl}
160
+ autoPlay
161
+ muted
162
+ loop
163
+ playsInline
164
+ className="w-full h-full object-contain"
165
+ />
166
+ ) : blockPreview.posterUrl ? (
167
+ <img
168
+ src={blockPreview.posterUrl}
169
+ alt={blockPreview.title}
170
+ className="w-full h-full object-contain"
171
+ />
172
+ ) : null}
173
+ </div>
174
+ );
175
+ }
176
+
177
+ if (captionEditMode) {
178
+ return <CaptionOverlay iframeRef={previewIframeRef} />;
179
+ }
180
+
181
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED) return null;
182
+
183
+ return (
184
+ <>
185
+ <DomEditOverlay
186
+ iframeRef={previewIframeRef}
187
+ activeCompositionPath={activeCompPath}
188
+ hoverSelection={
189
+ STUDIO_PREVIEW_SELECTION_ENABLED && !captionEditMode && !compositionLoading && !isPlaying
190
+ ? domEditHoverSelection
191
+ : null
192
+ }
193
+ selection={shouldShowSelectedDomBounds ? domEditSelection : null}
194
+ groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
195
+ allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !isGestureRecording}
196
+ onCanvasMouseDown={handlePreviewCanvasMouseDown}
197
+ onCanvasPointerMove={handlePreviewCanvasPointerMove}
198
+ onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
199
+ onSelectionChange={applyDomSelection}
200
+ onBlockedMove={handleBlockedDomMove}
201
+ onManualDragStart={handleDomManualDragStart}
202
+ onPathOffsetCommit={handleDomPathOffsetCommit}
203
+ onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
204
+ onBoxSizeCommit={handleDomBoxSizeCommit}
205
+ onRotationCommit={handleDomRotationCommit}
206
+ onStyleCommit={handleDomStyleCommit}
207
+ onDeleteSelection={handleDomEditElementDelete}
208
+ onApplyZIndex={(sel, patches) => {
209
+ const { entries, droppedLabels } = resolveZIndexEntries(sel, patches);
210
+ if (droppedLabels.length > 0) {
211
+ // The optimistic z-index has already been applied live at this point —
212
+ // these siblings just won't be written to source, so they'll revert to
213
+ // their prior stacking order on the next reload with no other signal.
214
+ console.warn(
215
+ "[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
216
+ "(will revert on reload):",
217
+ droppedLabels.join(", "),
218
+ );
219
+ }
220
+ if (entries.length > 0) handleDomZIndexReorderCommit(entries);
221
+ }}
222
+ gridVisible={snapPrefs.gridVisible}
223
+ gridSpacing={snapPrefs.gridSpacing}
224
+ recordingState={recordingState}
225
+ onToggleRecording={onToggleRecording}
226
+ onMarqueeSelect={applyMarqueeSelection}
227
+ />
228
+ <SnapToolbar onSnapChange={setSnapPrefs} />
229
+ {STUDIO_KEYFRAMES_ENABLED && (
230
+ <MotionPathOverlay
231
+ iframeRef={previewIframeRef}
232
+ selection={shouldShowSelectedDomBounds ? domEditSelection : null}
233
+ compositionSize={compositionDimensions}
234
+ isPlaying={isPlaying}
235
+ />
236
+ )}
237
+ {gestureOverlay}
238
+ </>
239
+ );
240
+ }