@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,163 @@
1
+ import { useCallback, useRef, useSyncExternalStore, type ReactNode } from "react";
2
+ import { PlayerControls } from "../../player";
3
+ import type { TimelineElement } from "../../player";
4
+ import { NLEPreview } from "./NLEPreview";
5
+ import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
6
+ import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
7
+ import { useNLEContext } from "./NLEContext";
8
+ import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
9
+
10
+ function subscribeFullscreen(cb: () => void) {
11
+ document.addEventListener("fullscreenchange", cb);
12
+ return () => document.removeEventListener("fullscreenchange", cb);
13
+ }
14
+
15
+ function getFullscreenElement() {
16
+ return document.fullscreenElement;
17
+ }
18
+
19
+ // Clear the timeline selection when a pointer lands outside the composition
20
+ // frame (clicks *inside* the frame are handled by the DOM-edit overlay).
21
+ // fallow-ignore-next-line complexity
22
+ function deselectIfPointerOutsideFrame(
23
+ e: React.PointerEvent,
24
+ iframe: HTMLIFrameElement | null,
25
+ onDeselect?: (element: null) => void,
26
+ ): void {
27
+ const el = iframe?.parentElement ?? iframe;
28
+ if (!el) return;
29
+ const rect = el.getBoundingClientRect();
30
+ const outside =
31
+ e.clientX < rect.left ||
32
+ e.clientX > rect.right ||
33
+ e.clientY < rect.top ||
34
+ e.clientY > rect.bottom;
35
+ if (outside) onDeselect?.(null);
36
+ }
37
+
38
+ export interface PreviewPaneProps {
39
+ portrait?: boolean;
40
+ /** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
41
+ previewOverlay?: ReactNode;
42
+ onSelectTimelineElement?: (element: TimelineElement | null) => void;
43
+ onPreviewBlockDrop?: (
44
+ blockName: string,
45
+ position: { left: number; top: number },
46
+ ) => Promise<void> | void;
47
+ }
48
+
49
+ // fallow-ignore-next-line complexity
50
+ export function PreviewPane({
51
+ portrait,
52
+ previewOverlay,
53
+ onSelectTimelineElement,
54
+ onPreviewBlockDrop,
55
+ }: PreviewPaneProps) {
56
+ const {
57
+ projectId,
58
+ iframeRef,
59
+ togglePlay,
60
+ seek,
61
+ onIframeLoad,
62
+ compositionStack,
63
+ handleNavigateComposition,
64
+ setCompositionLoading,
65
+ timelineDisabled,
66
+ hasLoadedOnceRef,
67
+ previewCompositionSize,
68
+ setPreviewCompositionSize,
69
+ } = useNLEContext();
70
+
71
+ const stageRefForDrop = useRef<HTMLDivElement | null>(null);
72
+ const handleStageRef = useCallback((ref: React.RefObject<HTMLDivElement | null>) => {
73
+ stageRefForDrop.current = ref.current;
74
+ }, []);
75
+
76
+ const {
77
+ isDragOver: previewDragOver,
78
+ handleDragEnter: handlePreviewDragEnter,
79
+ handleDragOver: handlePreviewDragOver,
80
+ handleDragLeave: handlePreviewDragLeave,
81
+ handleDrop: handlePreviewDrop,
82
+ } = usePreviewBlockDrop({
83
+ portrait,
84
+ compositionSize: previewCompositionSize,
85
+ stageRef: stageRefForDrop as React.RefObject<HTMLDivElement | null>,
86
+ onBlockDrop: onPreviewBlockDrop,
87
+ });
88
+
89
+ // Preview-only fullscreen: fullscreen targets THIS pane's container, so the
90
+ // browser shows only the preview (sidebars + timeline are excluded naturally).
91
+ const containerRef = useRef<HTMLDivElement>(null);
92
+ const fullscreenElement = useSyncExternalStore(subscribeFullscreen, getFullscreenElement);
93
+ const isFullscreen = fullscreenElement === containerRef.current && fullscreenElement != null;
94
+
95
+ const toggleFullscreen = useCallback(() => {
96
+ if (!containerRef.current) return;
97
+ if (document.fullscreenElement) {
98
+ void document.exitFullscreen();
99
+ } else {
100
+ void containerRef.current.requestFullscreen();
101
+ }
102
+ }, []);
103
+
104
+ const currentLevel = compositionStack[compositionStack.length - 1];
105
+ const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
106
+
107
+ return (
108
+ <div
109
+ ref={containerRef}
110
+ // Panel chrome (rounded border) is dropped in fullscreen so the preview
111
+ // fills the screen edge-to-edge.
112
+ className={`flex-1 min-h-0 flex flex-col overflow-hidden bg-neutral-950 ${
113
+ isFullscreen ? "" : "rounded-lg border border-neutral-800/50"
114
+ }`}
115
+ data-studio-fullscreen-target=""
116
+ >
117
+ <div
118
+ className="flex-1 min-h-0 relative overflow-hidden"
119
+ data-preview-pan-surface="true"
120
+ onPointerDown={(e) =>
121
+ deselectIfPointerOutsideFrame(e, iframeRef.current, onSelectTimelineElement)
122
+ }
123
+ onDragEnter={handlePreviewDragEnter}
124
+ onDragOver={handlePreviewDragOver}
125
+ onDragLeave={handlePreviewDragLeave}
126
+ onDrop={handlePreviewDrop}
127
+ >
128
+ <div className="absolute inset-0 overflow-hidden">
129
+ <NLEPreview
130
+ projectId={projectId}
131
+ iframeRef={iframeRef}
132
+ onIframeLoad={onIframeLoad}
133
+ onCompositionLoadingChange={setCompositionLoading}
134
+ portrait={portrait}
135
+ directUrl={directUrl}
136
+ suppressLoadingOverlay={hasLoadedOnceRef.current}
137
+ onStageRef={handleStageRef}
138
+ onCompositionSizeChange={setPreviewCompositionSize}
139
+ />
140
+ {previewDragOver && (
141
+ <div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
142
+ )}
143
+ <AssetPreviewOverlay />
144
+ </div>
145
+ {!isFullscreen && previewOverlay}
146
+ </div>
147
+ {/* Transport row: no own background or border — the controls sit flat on
148
+ the preview panel's surface (CapCut-style). */}
149
+ <div className="flex-shrink-0">
150
+ {!isFullscreen && compositionStack.length > 1 && (
151
+ <CompositionBreadcrumb stack={compositionStack} onNavigate={handleNavigateComposition} />
152
+ )}
153
+ <PlayerControls
154
+ onTogglePlay={togglePlay}
155
+ onSeek={seek}
156
+ disabled={timelineDisabled}
157
+ isFullscreen={isFullscreen}
158
+ onToggleFullscreen={toggleFullscreen}
159
+ />
160
+ </div>
161
+ </div>
162
+ );
163
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { TimelineElement } from "../../player";
3
+ import {
4
+ forwardRebasedTimelineMoveElements,
5
+ forwardRebasedTimelineResizeElements,
6
+ } from "./TimelinePane";
7
+
8
+ describe("TimelinePane move wrapper", () => {
9
+ it("rebases expanded edits and forwards track-insert as the third argument", async () => {
10
+ const onMoveElements = vi.fn().mockResolvedValue(undefined);
11
+ const element: TimelineElement = {
12
+ id: "expanded-a",
13
+ domId: "a",
14
+ tag: "div",
15
+ start: 12,
16
+ duration: 2,
17
+ track: 0,
18
+ expandedParentStart: 10,
19
+ };
20
+ await forwardRebasedTimelineMoveElements(
21
+ [{ element, updates: { start: 14, track: 2 } }],
22
+ "clip-lane-move:7",
23
+ "track-insert",
24
+ onMoveElements,
25
+ );
26
+ expect(onMoveElements).toHaveBeenCalledWith(
27
+ [
28
+ {
29
+ element: expect.objectContaining({ id: "a", start: 2 }),
30
+ updates: { start: 4, track: 2 },
31
+ },
32
+ ],
33
+ "clip-lane-move:7",
34
+ "track-insert",
35
+ );
36
+ });
37
+
38
+ it("forwards one rebased resize batch with the shared gesture key", async () => {
39
+ const onResizeElements = vi.fn().mockResolvedValue(undefined);
40
+ const element: TimelineElement = {
41
+ id: "expanded-a",
42
+ domId: "a",
43
+ tag: "div",
44
+ start: 12,
45
+ duration: 2,
46
+ track: 0,
47
+ expandedParentStart: 10,
48
+ };
49
+ await forwardRebasedTimelineResizeElements(
50
+ [{ element, start: 13, duration: 3 }],
51
+ { coalesceKey: "clip-group-resize:a:b" },
52
+ onResizeElements,
53
+ );
54
+ expect(onResizeElements).toHaveBeenCalledTimes(1);
55
+ expect(onResizeElements).toHaveBeenCalledWith(
56
+ [{ element: expect.objectContaining({ id: "a", start: 2 }), start: 3, duration: 3 }],
57
+ { coalesceKey: "clip-group-resize:a:b" },
58
+ );
59
+ });
60
+ });
@@ -0,0 +1,293 @@
1
+ import { useCallback, type ReactNode } from "react";
2
+ import { Timeline } from "../../player";
3
+ import type { TimelineElement } from "../../player";
4
+ import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
5
+ import { TimelineResizeDivider } from "./TimelineResizeDivider";
6
+ import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
7
+ import { trackStudioExpandedClipEdit } from "../../telemetry/events";
8
+ import { useNLEContext } from "./NLEContext";
9
+ import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
10
+
11
+ type TimelineMoveEdit = {
12
+ element: TimelineElement;
13
+ updates: Pick<TimelineElement, "start" | "track">;
14
+ };
15
+
16
+ export function forwardRebasedTimelineMoveElements(
17
+ edits: TimelineMoveEdit[],
18
+ coalesceKey: string | undefined,
19
+ operation: TimelineMoveOperation | undefined,
20
+ onMoveElements: (
21
+ edits: TimelineMoveEdit[],
22
+ coalesceKey?: string,
23
+ operation?: TimelineMoveOperation,
24
+ ) => Promise<void> | void,
25
+ ) {
26
+ return onMoveElements(
27
+ edits.map(({ element, updates }) => {
28
+ const basis = element.expandedParentStart;
29
+ if (basis === undefined) return { element, updates };
30
+ return {
31
+ element: { ...element, id: element.domId ?? element.id, start: element.start - basis },
32
+ updates: { ...updates, start: Math.max(0, updates.start - basis) },
33
+ };
34
+ }),
35
+ coalesceKey,
36
+ operation,
37
+ );
38
+ }
39
+
40
+ type TimelineResizeChange = {
41
+ element: TimelineElement;
42
+ start: number;
43
+ duration: number;
44
+ playbackStart?: number;
45
+ };
46
+
47
+ export function forwardRebasedTimelineResizeElements(
48
+ changes: TimelineResizeChange[],
49
+ options: { coalesceKey?: string } | undefined,
50
+ onResizeElements: (
51
+ changes: TimelineResizeChange[],
52
+ options?: { coalesceKey?: string },
53
+ ) => Promise<void> | void,
54
+ ) {
55
+ return onResizeElements(
56
+ changes.map((change) => {
57
+ const basis = change.element.expandedParentStart;
58
+ if (basis === undefined) return change;
59
+ return {
60
+ ...change,
61
+ element: {
62
+ ...change.element,
63
+ id: change.element.domId ?? change.element.id,
64
+ start: change.element.start - basis,
65
+ },
66
+ start: Math.max(0, change.start - basis),
67
+ };
68
+ }),
69
+ options,
70
+ );
71
+ }
72
+
73
+ export interface TimelinePaneProps {
74
+ /** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */
75
+ timelineToolbar?: ReactNode;
76
+ /** Slot rendered below the timeline tracks */
77
+ timelineFooter?: ReactNode;
78
+ /** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
79
+ renderClipContent?: (
80
+ element: TimelineElement,
81
+ style: { clip: string; label: string },
82
+ ) => ReactNode;
83
+ onFileDrop?: (
84
+ files: File[],
85
+ placement?: Pick<TimelineElement, "start" | "track">,
86
+ ) => Promise<void> | void;
87
+ onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
88
+ onAssetDrop?: (
89
+ assetPath: string,
90
+ placement: Pick<TimelineElement, "start" | "track">,
91
+ ) => Promise<void> | void;
92
+ onBlockDrop?: (
93
+ blockName: string,
94
+ placement: Pick<TimelineElement, "start" | "track">,
95
+ ) => Promise<void> | void;
96
+ onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
97
+ onSelectTimelineElement?: (element: TimelineElement | null) => void;
98
+ }
99
+
100
+ // fallow-ignore-next-line complexity
101
+ export function TimelinePane({
102
+ timelineToolbar,
103
+ timelineFooter,
104
+ renderClipContent,
105
+ onFileDrop,
106
+ onDeleteElement,
107
+ onAssetDrop,
108
+ onBlockDrop,
109
+ onBlockedEditAttempt,
110
+ onSelectTimelineElement,
111
+ }: TimelinePaneProps) {
112
+ const {
113
+ seek,
114
+ handleDrillDown,
115
+ compositionStack,
116
+ updateCompositionStack,
117
+ timelineH,
118
+ setTimelineH,
119
+ persistTimelineH,
120
+ containerRef,
121
+ timelineDisabled,
122
+ } = useNLEContext();
123
+
124
+ // Move/resize/split come from the timeline edit context, not props — the
125
+ // wrappers below intercept expanded clips and must call the *real* handlers.
126
+ // (Delete is a direct prop; it stays that way.)
127
+ const { onMoveElement, onMoveElements, onResizeElement, onResizeElements, onSplitElement } =
128
+ useTimelineEditContext();
129
+
130
+ // An expanded sub-comp child reaches the normal edit handlers in its own
131
+ // local coordinates: addressed by its real DOM id, with timeline time rebased
132
+ // onto the sub-comp it lives in. The handlers then save + reloadPreview exactly
133
+ // as they do for top-level clips — no separate live-DOM path.
134
+ const toLocalElement = useCallback(
135
+ (element: TimelineElement, basis: number): TimelineElement => ({
136
+ ...element,
137
+ id: element.domId ?? element.id,
138
+ start: element.start - basis,
139
+ }),
140
+ [],
141
+ );
142
+
143
+ const handleMoveElement = useCallback(
144
+ (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
145
+ const basis = element.expandedParentStart;
146
+ if (basis === undefined) return onMoveElement?.(element, updates);
147
+ trackStudioExpandedClipEdit({ action: "move" });
148
+ onMoveElement?.(toLocalElement(element, basis), {
149
+ ...updates,
150
+ start: Math.max(0, updates.start - basis),
151
+ });
152
+ },
153
+ [onMoveElement, toLocalElement],
154
+ );
155
+
156
+ // Batched move (ripple / insert): rebase each expanded sub-comp child to its
157
+ // local coords, exactly as handleMoveElement does for a single clip.
158
+ const handleMoveElements = useCallback(
159
+ (
160
+ edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
161
+ coalesceKey?: string,
162
+ operation?: TimelineMoveOperation,
163
+ ) => {
164
+ // Match the sibling handlers: report the telemetry when the batch touches at
165
+ // least one expanded sub-comp child (the clips being rebased to local coords).
166
+ if (edits.some(({ element }) => element.expandedParentStart !== undefined)) {
167
+ trackStudioExpandedClipEdit({ action: "move" });
168
+ }
169
+ if (!onMoveElements) return;
170
+ return forwardRebasedTimelineMoveElements(edits, coalesceKey, operation, onMoveElements);
171
+ },
172
+ [onMoveElements],
173
+ );
174
+
175
+ const handleResizeElement = useCallback(
176
+ (
177
+ element: TimelineElement,
178
+ updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
179
+ ) => {
180
+ const basis = element.expandedParentStart;
181
+ if (basis === undefined) return onResizeElement?.(element, updates);
182
+ trackStudioExpandedClipEdit({ action: "resize" });
183
+ onResizeElement?.(toLocalElement(element, basis), {
184
+ ...updates,
185
+ start: Math.max(0, updates.start - basis),
186
+ });
187
+ },
188
+ [onResizeElement, toLocalElement],
189
+ );
190
+
191
+ const handleResizeElements = useCallback(
192
+ (
193
+ changes: Array<{
194
+ element: TimelineElement;
195
+ start: number;
196
+ duration: number;
197
+ playbackStart?: number;
198
+ }>,
199
+ options?: { coalesceKey?: string },
200
+ ) => {
201
+ if (!onResizeElements) return;
202
+ if (changes.some(({ element }) => element.expandedParentStart !== undefined)) {
203
+ trackStudioExpandedClipEdit({ action: "resize" });
204
+ }
205
+ return forwardRebasedTimelineResizeElements(changes, options, onResizeElements);
206
+ },
207
+ [onResizeElements],
208
+ );
209
+
210
+ const handleDeleteElement = useCallback(
211
+ (element: TimelineElement) => {
212
+ const basis = element.expandedParentStart;
213
+ if (basis === undefined) return onDeleteElement?.(element);
214
+ trackStudioExpandedClipEdit({ action: "delete" });
215
+ return onDeleteElement?.(toLocalElement(element, basis));
216
+ },
217
+ [onDeleteElement, toLocalElement],
218
+ );
219
+
220
+ const handleSplitElement = useCallback(
221
+ (element: TimelineElement, splitTime: number) => {
222
+ const basis = element.expandedParentStart;
223
+ if (basis === undefined) return onSplitElement?.(element, splitTime);
224
+ trackStudioExpandedClipEdit({ action: "split" });
225
+ return onSplitElement?.(toLocalElement(element, basis), Math.max(0, splitTime - basis));
226
+ },
227
+ [onSplitElement, toLocalElement],
228
+ );
229
+
230
+ return (
231
+ <>
232
+ <TimelineResizeDivider
233
+ timelineH={timelineH}
234
+ setTimelineH={setTimelineH}
235
+ persistTimelineH={persistTimelineH}
236
+ containerRef={containerRef}
237
+ disabled={timelineDisabled}
238
+ />
239
+
240
+ {/* Timeline section — inner padding (not margin) keeps the divider's
241
+ height math exact while giving the panel a gap from the shell edges. */}
242
+ <div
243
+ className="relative flex flex-col flex-shrink-0 px-px pb-px"
244
+ style={{ height: timelineH }}
245
+ aria-disabled={timelineDisabled || undefined}
246
+ >
247
+ <div
248
+ className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
249
+ onDoubleClick={(e) => {
250
+ if ((e.target as HTMLElement).closest("[data-clip]")) return;
251
+ if (timelineDisabled) return;
252
+ if (compositionStack.length > 1) {
253
+ updateCompositionStack((prev) => prev.slice(0, -1));
254
+ }
255
+ }}
256
+ >
257
+ <div className="flex-shrink-0">{timelineToolbar}</div>
258
+ <Timeline
259
+ onSeek={seek}
260
+ onDrillDown={handleDrillDown}
261
+ renderClipContent={renderClipContent}
262
+ onFileDrop={onFileDrop}
263
+ onDeleteElement={handleDeleteElement}
264
+ onAssetDrop={onAssetDrop}
265
+ onBlockDrop={onBlockDrop}
266
+ onMoveElement={handleMoveElement}
267
+ onMoveElements={handleMoveElements}
268
+ onResizeElement={handleResizeElement}
269
+ onResizeElements={handleResizeElements}
270
+ onBlockedEditAttempt={onBlockedEditAttempt}
271
+ onSplitElement={handleSplitElement}
272
+ onSelectElement={onSelectTimelineElement}
273
+ />
274
+ </div>
275
+ {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
276
+ {timelineDisabled && (
277
+ <div
278
+ className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
279
+ data-testid="timeline-loading-disabled-overlay"
280
+ role="status"
281
+ onPointerDown={(event) => event.preventDefault()}
282
+ onDragOver={(event) => event.preventDefault()}
283
+ onDrop={(event) => event.preventDefault()}
284
+ >
285
+ <span className="rounded-md bg-neutral-900/90 px-2.5 py-1 text-[11px] text-neutral-400">
286
+ Loading composition…
287
+ </span>
288
+ </div>
289
+ )}
290
+ </div>
291
+ </>
292
+ );
293
+ }
@@ -73,6 +73,9 @@ export function TimelineResizeDivider({
73
73
  );
74
74
 
75
75
  return (
76
+ // Horizontal resize divider: 3px visible seam (h-[3px]), 8px pointer-capture
77
+ // zone via the absolutely-positioned inner hit area so the layout gap stays
78
+ // at 3px while draggability is preserved over the full 8px band.
76
79
  <div
77
80
  role="separator"
78
81
  aria-orientation="horizontal"
@@ -83,7 +86,7 @@ export function TimelineResizeDivider({
83
86
  (containerRef.current?.getBoundingClientRect().height ?? 600) - MIN_PREVIEW_H,
84
87
  )}
85
88
  tabIndex={0}
86
- className="group h-2 flex-shrink-0 cursor-row-resize flex items-center justify-center z-10 outline-none focus-visible:bg-studio-accent/20"
89
+ className="group relative h-[3px] flex-shrink-0 cursor-row-resize z-10 outline-none focus-visible:bg-studio-accent/20"
87
90
  style={{ touchAction: "none" }}
88
91
  onPointerDown={handlePointerDown}
89
92
  onPointerMove={handlePointerMove}
@@ -91,7 +94,10 @@ export function TimelineResizeDivider({
91
94
  onPointerCancel={handlePointerUp}
92
95
  onKeyDown={handleKeyDown}
93
96
  >
94
- <div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22 group-focus-visible:bg-studio-accent/60" />
97
+ {/* Expanded hit zone: 8px tall, centered on the 3px seam */}
98
+ <div className="absolute inset-x-0 -top-[2.5px] h-2" />
99
+ {/* Visible hairline — invisible at rest, subtle wash on hover/drag/focus */}
100
+ <div className="h-[3px] w-full bg-transparent transition-colors group-hover:bg-white/12 group-active:bg-white/18 group-focus-visible:bg-studio-accent/60" />
95
101
  </div>
96
102
  );
97
103
  }
@@ -0,0 +1,44 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
6
+ import { useCompositionStack } from "./useCompositionStack";
7
+
8
+ installReactActEnvironment();
9
+
10
+ describe("useCompositionStack — project scoping", () => {
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ for (const activeCompositionPath of [null, "index.html"] as const) {
16
+ it(`rebuilds the master preview URL on a project switch with ${String(activeCompositionPath)}`, async () => {
17
+ const host = document.createElement("div");
18
+ document.body.append(host);
19
+ const root = createRoot(host);
20
+ let previewUrl = "";
21
+
22
+ function Harness(props: { projectId: string; activeCompositionPath: string | null }) {
23
+ previewUrl = useCompositionStack(props).compositionStack[0]?.previewUrl ?? "";
24
+ return null;
25
+ }
26
+
27
+ await act(async () => {
28
+ root.render(
29
+ <Harness projectId="project-a" activeCompositionPath={activeCompositionPath} />,
30
+ );
31
+ });
32
+ expect(previewUrl).toBe("/api/projects/project-a/preview");
33
+
34
+ await act(async () => {
35
+ root.render(
36
+ <Harness projectId="project-b" activeCompositionPath={activeCompositionPath} />,
37
+ );
38
+ });
39
+ expect(previewUrl).toBe("/api/projects/project-b/preview");
40
+
41
+ act(() => root.unmount());
42
+ });
43
+ }
44
+ });
@@ -1,7 +1,8 @@
1
- // Composition drill-down stack management for NLELayout
1
+ // Composition drill-down stack management for NLEContext/EditorShell
2
2
  import { useState, useCallback, useRef, useEffect } from "react";
3
3
  import { usePlayerStore } from "../../player";
4
4
  import type { CompositionLevel } from "./CompositionBreadcrumb";
5
+ import { encodePreviewPath } from "../../player/components/thumbnailUtils";
5
6
 
6
7
  interface UseCompositionStackOptions {
7
8
  projectId: string;
@@ -25,7 +26,11 @@ export function useCompositionStack({
25
26
  onCompositionChange,
26
27
  }: UseCompositionStackOptions): UseCompositionStackResult {
27
28
  const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
28
- { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
29
+ {
30
+ id: "master",
31
+ label: "Master",
32
+ previewUrl: `/api/projects/${projectId}/preview`,
33
+ },
29
34
  ]);
30
35
 
31
36
  const onCompositionChangeRef = useRef(onCompositionChange);
@@ -84,7 +89,7 @@ export function useCompositionStack({
84
89
  .split("/")
85
90
  .pop()
86
91
  ?.replace(/\.html$/, "") || resolvedPath;
87
- const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
92
+ const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(resolvedPath)}`;
88
93
  return [...prev, { id: resolvedPath, label, previewUrl }];
89
94
  });
90
95
  },
@@ -95,22 +100,25 @@ export function useCompositionStack({
95
100
  // Navigate to a composition when activeCompositionPath changes.
96
101
  // eslint-disable-next-line no-restricted-syntax
97
102
  useEffect(() => {
103
+ const master: CompositionLevel = {
104
+ id: "master",
105
+ label: "Master",
106
+ previewUrl: `/api/projects/${projectId}/preview`,
107
+ };
98
108
  if (activeCompositionPath === "index.html") {
99
109
  usePlayerStore.getState().setElements([]);
100
- updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
110
+ updateCompositionStack([master]);
101
111
  } else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
102
112
  const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
103
- const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
113
+ const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(activeCompositionPath)}`;
104
114
  usePlayerStore.getState().setElements([]);
105
115
  updateCompositionStack((prev) => {
106
116
  if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
107
- return [
108
- { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
109
- { id: activeCompositionPath, label, previewUrl },
110
- ];
117
+ return [master, { id: activeCompositionPath, label, previewUrl }];
111
118
  });
112
119
  } else if (!activeCompositionPath) {
113
120
  usePlayerStore.getState().setElements([]);
121
+ updateCompositionStack([master]);
114
122
  }
115
123
  }, [activeCompositionPath, projectId, updateCompositionStack]);
116
124