@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.
- package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40582 -37803
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +158 -126
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +43 -1
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +240 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.tsx +326 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +39 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +303 -0
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +477 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/player/index.ts +3 -2
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/assets/index-pRhCpGPz.js +0 -423
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /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-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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(
|
|
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
|
|