@hyperframes/studio 0.7.55 → 0.7.57
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/hyperframes-player--Z69cEkE.js +459 -0
- package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/index-DfmYkU44.js +423 -0
- package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.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 +172 -144
- package/dist/index.html +2 -2
- package/dist/index.js +43613 -40346
- 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 +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- 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 +126 -125
- 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 +53 -2
- 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 +106 -10
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- 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/propertyPanelColorGradingSection.tsx +15 -21
- 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/useCanvasContextMenuState.ts +92 -0
- 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/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +171 -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 +260 -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.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +343 -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 +56 -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 +23 -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 +125 -1
- package/src/hooks/timelineEditingHelpers.ts +53 -292
- package/src/hooks/timelineMoveAdapter.test.ts +81 -0
- package/src/hooks/timelineMoveAdapter.ts +39 -0
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -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/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +245 -1
- package/src/hooks/useDomEditCommits.ts +162 -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 +423 -0
- package/src/hooks/useElementLifecycleOps.ts +53 -71
- package/src/hooks/useElementPicker.ts +7 -8
- 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 +148 -18
- 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/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +398 -241
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +167 -132
- 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/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +55 -5
- package/src/player/components/Timeline.test.ts +183 -23
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +218 -523
- 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/TimelineRuler.tsx +58 -48
- 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 +1026 -0
- package/src/player/components/timelineClipDragCommit.ts +498 -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 +42 -2
- 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 +207 -75
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- 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/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
- package/src/player/components/useTimelineStackingSync.ts +94 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +52 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +5 -2
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- 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 +21 -81
- package/src/player/lib/timelineElementHelpers.ts +73 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +99 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- 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/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +67 -29
- package/src/utils/timelineDiscovery.ts +0 -17
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-BRwkMj0w.js +0 -423
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- 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,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Z-order "show your work" flash: after a bring-forward / send-backward, the
|
|
3
|
+
* sibling that was stepped over gets a brief (600ms) highlight so the action
|
|
4
|
+
* is legible even when the visual change is subtle.
|
|
5
|
+
*
|
|
6
|
+
* Drawn in the STUDIO's own overlay layer above the preview iframe — nothing
|
|
7
|
+
* is written into the iframe DOM or the composition, so a concurrent preview
|
|
8
|
+
* reload can never leave a stuck highlight; the timeout merely clears
|
|
9
|
+
* studio-local state. The crossed element is resolved by the context menu
|
|
10
|
+
* (resolveCrossedNeighbor) from the same pre-mutation render order as the
|
|
11
|
+
* z patches; z-index writes don't move layout, so measuring its rect after
|
|
12
|
+
* the commit applied live styles is still accurate.
|
|
13
|
+
*/
|
|
14
|
+
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
|
15
|
+
import { toVisibleOverlayRect, type OverlayRect } from "./domEditOverlayGeometry";
|
|
16
|
+
|
|
17
|
+
const Z_ORDER_CROSSED_FLASH_MS = 600;
|
|
18
|
+
|
|
19
|
+
interface UseZOrderCrossedFlashParams {
|
|
20
|
+
overlayRef: RefObject<HTMLDivElement | null>;
|
|
21
|
+
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useZOrderCrossedFlash({ overlayRef, iframeRef }: UseZOrderCrossedFlashParams): {
|
|
25
|
+
zOrderFlashRect: OverlayRect | null;
|
|
26
|
+
handleZOrderCrossed: (crossed: HTMLElement) => void;
|
|
27
|
+
} {
|
|
28
|
+
const [zOrderFlashRect, setZOrderFlashRect] = useState<OverlayRect | null>(null);
|
|
29
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
30
|
+
useEffect(
|
|
31
|
+
() => () => {
|
|
32
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
33
|
+
},
|
|
34
|
+
[],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const handleZOrderCrossed = useCallback(
|
|
38
|
+
(crossed: HTMLElement) => {
|
|
39
|
+
const overlayEl = overlayRef.current;
|
|
40
|
+
const iframe = iframeRef.current;
|
|
41
|
+
if (!overlayEl || !iframe) return;
|
|
42
|
+
const rect = toVisibleOverlayRect(overlayEl, iframe, crossed);
|
|
43
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
|
44
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
45
|
+
setZOrderFlashRect(rect);
|
|
46
|
+
timeoutRef.current = setTimeout(() => {
|
|
47
|
+
timeoutRef.current = null;
|
|
48
|
+
setZOrderFlashRect(null);
|
|
49
|
+
}, Z_ORDER_CROSSED_FLASH_MS);
|
|
50
|
+
},
|
|
51
|
+
[overlayRef, iframeRef],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return { zOrderFlashRect, handleZOrderCrossed };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The flash chrome itself — a pulsing accent outline over the crossed sibling. */
|
|
58
|
+
export function ZOrderCrossedFlash({ rect }: { rect: OverlayRect | null }) {
|
|
59
|
+
if (!rect) return null;
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
data-dom-edit-z-flash="true"
|
|
64
|
+
className="pointer-events-none absolute rounded-md border-2 border-studio-accent shadow-[0_0_0_2px_rgba(60,230,172,0.35)] animate-pulse"
|
|
65
|
+
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
7
|
+
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
8
|
+
import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
let root: Root | null = null;
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (root) {
|
|
16
|
+
act(() => root?.unmount());
|
|
17
|
+
root = null;
|
|
18
|
+
}
|
|
19
|
+
document.body.innerHTML = "";
|
|
20
|
+
usePlayerStore.getState().reset();
|
|
21
|
+
// reset() does not cover the out-of-loop seek request; clear it explicitly
|
|
22
|
+
// so a pending-seek test can't leak into the next one.
|
|
23
|
+
usePlayerStore.getState().clearSeekRequest();
|
|
24
|
+
useAssetPreviewStore.getState().clearPreviewAsset();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function mountOverlay(): void {
|
|
28
|
+
const host = document.createElement("div");
|
|
29
|
+
document.body.append(host);
|
|
30
|
+
root = createRoot(host);
|
|
31
|
+
act(() => {
|
|
32
|
+
root?.render(<AssetPreviewOverlay />);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("AssetPreviewOverlay playback dismissal", () => {
|
|
37
|
+
it("dismisses immediately when opened while playback is ALREADY running", () => {
|
|
38
|
+
// The RAF playback loop bypasses the store, so no post-open store change
|
|
39
|
+
// will arrive — the dismiss check must be level-triggered, not edge-triggered.
|
|
40
|
+
usePlayerStore.setState({ isPlaying: true });
|
|
41
|
+
mountOverlay();
|
|
42
|
+
|
|
43
|
+
act(() => {
|
|
44
|
+
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
|
48
|
+
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("dismisses immediately when opened while a seek is ALREADY in flight", () => {
|
|
52
|
+
// A pending out-of-loop seek (requestedSeekTime set, not yet consumed) may
|
|
53
|
+
// produce no further store change before currentTime settles — the on-open
|
|
54
|
+
// check must evaluate the full shared dismiss predicate, not just isPlaying.
|
|
55
|
+
usePlayerStore.setState({ isPlaying: false, requestedSeekTime: 3.2 });
|
|
56
|
+
mountOverlay();
|
|
57
|
+
|
|
58
|
+
act(() => {
|
|
59
|
+
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
|
63
|
+
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("stays open when the playhead is idle", () => {
|
|
67
|
+
mountOverlay();
|
|
68
|
+
|
|
69
|
+
act(() => {
|
|
70
|
+
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
|
|
74
|
+
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("still dismisses when playback starts AFTER the preview opened", () => {
|
|
78
|
+
mountOverlay();
|
|
79
|
+
|
|
80
|
+
act(() => {
|
|
81
|
+
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
|
|
82
|
+
});
|
|
83
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
|
|
84
|
+
|
|
85
|
+
act(() => {
|
|
86
|
+
usePlayerStore.setState({ isPlaying: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("still dismisses when the playhead is scrubbed after opening", () => {
|
|
93
|
+
usePlayerStore.setState({ currentTime: 2 });
|
|
94
|
+
mountOverlay();
|
|
95
|
+
|
|
96
|
+
act(() => {
|
|
97
|
+
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
act(() => {
|
|
101
|
+
usePlayerStore.setState({ currentTime: 4.5 });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CapCut-style asset preview overlay rendered inside PreviewPane.
|
|
3
|
+
*
|
|
4
|
+
* Shown when the user clicks an asset card that has NOT yet been added to the
|
|
5
|
+
* timeline. Displays the media (image / video / audio) as a compact floating
|
|
6
|
+
* card over the canvas — the canvas stays visible behind a barely-tinted
|
|
7
|
+
* click-catcher — without modifying the composition (no undo entry, no file
|
|
8
|
+
* mutation).
|
|
9
|
+
*
|
|
10
|
+
* Dismiss: X button, Escape key, click outside the card, or any playhead
|
|
11
|
+
* activity (starting playback / seeking) — the canvas refocuses.
|
|
12
|
+
* Switching to another not-added asset replaces the current preview.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useCallback } from "react";
|
|
15
|
+
import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
|
|
16
|
+
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
17
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
18
|
+
import { shouldDismissAssetPreview } from "../../utils/assetPreviewDismiss";
|
|
19
|
+
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
|
|
20
|
+
|
|
21
|
+
function basename(path: string): string {
|
|
22
|
+
return path.split("/").pop() ?? path;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type AssetKind = "image" | "video" | "audio";
|
|
26
|
+
|
|
27
|
+
function resolveAssetKind(path: string): AssetKind {
|
|
28
|
+
if (VIDEO_EXT.test(path)) return "video";
|
|
29
|
+
if (IMAGE_EXT.test(path)) return "image";
|
|
30
|
+
return "audio";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The media element for a previewed asset, chosen by kind. */
|
|
34
|
+
function AssetPreviewMedia({
|
|
35
|
+
kind,
|
|
36
|
+
serveUrl,
|
|
37
|
+
name,
|
|
38
|
+
}: {
|
|
39
|
+
kind: AssetKind;
|
|
40
|
+
serveUrl: string;
|
|
41
|
+
name: string;
|
|
42
|
+
}) {
|
|
43
|
+
if (kind === "image") {
|
|
44
|
+
return (
|
|
45
|
+
<img src={serveUrl} alt={name} className="max-w-full max-h-[40vh] rounded object-contain" />
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (kind === "video") {
|
|
49
|
+
return (
|
|
50
|
+
<video
|
|
51
|
+
src={serveUrl}
|
|
52
|
+
controls
|
|
53
|
+
autoPlay
|
|
54
|
+
muted
|
|
55
|
+
playsInline
|
|
56
|
+
className="max-w-full max-h-[40vh] rounded"
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex flex-col items-center gap-3 px-6 py-4">
|
|
62
|
+
<svg
|
|
63
|
+
width="40"
|
|
64
|
+
height="40"
|
|
65
|
+
viewBox="0 0 24 24"
|
|
66
|
+
fill="none"
|
|
67
|
+
stroke="currentColor"
|
|
68
|
+
strokeWidth="1.5"
|
|
69
|
+
className="text-neutral-500"
|
|
70
|
+
>
|
|
71
|
+
<path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
|
|
72
|
+
<circle cx="6" cy="18" r="3" />
|
|
73
|
+
<circle cx="18" cy="16" r="3" />
|
|
74
|
+
</svg>
|
|
75
|
+
<audio src={serveUrl} controls className="w-64" />
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function AssetPreviewOverlay() {
|
|
81
|
+
const previewAsset = useAssetPreviewStore((s) => s.previewAsset);
|
|
82
|
+
const previewProjectId = useAssetPreviewStore((s) => s.previewProjectId);
|
|
83
|
+
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
|
|
84
|
+
|
|
85
|
+
const handleKeyDown = useCallback(
|
|
86
|
+
(e: KeyboardEvent) => {
|
|
87
|
+
if (e.key === "Escape") clearPreviewAsset();
|
|
88
|
+
},
|
|
89
|
+
[clearPreviewAsset],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!previewAsset) return;
|
|
94
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
95
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
96
|
+
}, [previewAsset, handleKeyDown]);
|
|
97
|
+
|
|
98
|
+
// The canvas refocuses on any playhead activity: starting playback or a
|
|
99
|
+
// seek/scrub away from where the playhead sat when the preview opened
|
|
100
|
+
// dismisses it. openedTime is captured per preview open (previewAsset dep),
|
|
101
|
+
// so a stale render can never dismiss against the wrong reference time.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!previewAsset) return;
|
|
104
|
+
const opened = usePlayerStore.getState();
|
|
105
|
+
const openedTime = opened.currentTime;
|
|
106
|
+
// Level-triggered, not edge-triggered: a preview opened while playback is
|
|
107
|
+
// ALREADY running (the RAF loop bypasses the store) or while a seek is
|
|
108
|
+
// already in flight gets no store change to react to, so evaluate the
|
|
109
|
+
// current state once, through the same shared predicate the subscription
|
|
110
|
+
// uses. openedTime is this snapshot's own currentTime, so the
|
|
111
|
+
// time-diverged branch can't false-positive at open — only the
|
|
112
|
+
// isPlaying / requestedSeekTime branches can fire here.
|
|
113
|
+
if (shouldDismissAssetPreview(openedTime, opened)) {
|
|
114
|
+
clearPreviewAsset();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
return usePlayerStore.subscribe((state) => {
|
|
118
|
+
if (shouldDismissAssetPreview(openedTime, state)) clearPreviewAsset();
|
|
119
|
+
});
|
|
120
|
+
}, [previewAsset, clearPreviewAsset]);
|
|
121
|
+
|
|
122
|
+
if (!previewAsset || !previewProjectId) return null;
|
|
123
|
+
|
|
124
|
+
const serveUrl = resolveMediaPreviewUrl(previewAsset, previewProjectId);
|
|
125
|
+
const name = basename(previewAsset);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/20"
|
|
130
|
+
onClick={clearPreviewAsset}
|
|
131
|
+
role="dialog"
|
|
132
|
+
aria-label={`Preview: ${name}`}
|
|
133
|
+
>
|
|
134
|
+
{/* Floating preview card — compact, canvas stays visible around it */}
|
|
135
|
+
<div
|
|
136
|
+
className="relative flex flex-col items-center gap-2 max-w-[58%] rounded-lg border border-neutral-800 bg-neutral-950/95 p-2 pt-8 shadow-2xl"
|
|
137
|
+
onClick={(e) => e.stopPropagation()}
|
|
138
|
+
>
|
|
139
|
+
{/* Close button */}
|
|
140
|
+
<button
|
|
141
|
+
className="absolute top-2 right-2 w-6 h-6 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
|
|
142
|
+
onClick={(e) => {
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
clearPreviewAsset();
|
|
145
|
+
}}
|
|
146
|
+
aria-label="Close preview"
|
|
147
|
+
>
|
|
148
|
+
<svg
|
|
149
|
+
width="12"
|
|
150
|
+
height="12"
|
|
151
|
+
viewBox="0 0 24 24"
|
|
152
|
+
stroke="currentColor"
|
|
153
|
+
strokeWidth="2.5"
|
|
154
|
+
fill="none"
|
|
155
|
+
strokeLinecap="round"
|
|
156
|
+
>
|
|
157
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
158
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
159
|
+
</svg>
|
|
160
|
+
</button>
|
|
161
|
+
|
|
162
|
+
<AssetPreviewMedia kind={resolveAssetKind(previewAsset)} serveUrl={serveUrl} name={name} />
|
|
163
|
+
|
|
164
|
+
{/* Filename label */}
|
|
165
|
+
<span className="text-[12px] text-neutral-400 truncate max-w-full px-2 text-center">
|
|
166
|
+
{name}
|
|
167
|
+
</span>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { shouldDisableTimelineWhileCompositionLoading, NLEProvider } from "./NLEContext";
|
|
6
|
+
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
7
|
+
import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
|
|
8
|
+
|
|
9
|
+
installReactActEnvironment();
|
|
10
|
+
|
|
11
|
+
// Render NLEProvider into a fresh detached host and settle the mount effect.
|
|
12
|
+
async function mountNleProvider(props: {
|
|
13
|
+
projectId: string;
|
|
14
|
+
refreshKey?: number;
|
|
15
|
+
}): Promise<{ host: HTMLDivElement; root: Root }> {
|
|
16
|
+
const host = document.createElement("div");
|
|
17
|
+
document.body.append(host);
|
|
18
|
+
const root = createRoot(host);
|
|
19
|
+
await renderNleProvider(root, props);
|
|
20
|
+
return { host, root };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Re-render NLEProvider into an existing root and settle effects.
|
|
24
|
+
async function renderNleProvider(
|
|
25
|
+
root: Root,
|
|
26
|
+
props: {
|
|
27
|
+
projectId: string;
|
|
28
|
+
refreshKey?: number;
|
|
29
|
+
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
|
30
|
+
},
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
await act(async () => {
|
|
33
|
+
root.render(React.createElement(NLEProvider, props, React.createElement("div")));
|
|
34
|
+
await Promise.resolve();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("timeline loading disable state", () => {
|
|
39
|
+
it("disables the timeline while the composition loading overlay is visible", () => {
|
|
40
|
+
expect(shouldDisableTimelineWhileCompositionLoading(true)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reenables the timeline after composition loading finishes", () => {
|
|
44
|
+
expect(shouldDisableTimelineWhileCompositionLoading(false)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("NLEProvider — asset preview scoping", () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
// No project API in this unit test — stub fetch so the compIdToSrc mount
|
|
51
|
+
// effect's request rejects quietly instead of hitting the network.
|
|
52
|
+
vi.stubGlobal(
|
|
53
|
+
"fetch",
|
|
54
|
+
vi.fn(() => Promise.reject(new Error("no network in tests"))),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
vi.unstubAllGlobals();
|
|
60
|
+
useAssetPreviewStore.getState().clearPreviewAsset();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("clears a stale cross-project asset preview when the active project changes", async () => {
|
|
64
|
+
const { host, root } = await mountNleProvider({ projectId: "project-a" });
|
|
65
|
+
|
|
66
|
+
useAssetPreviewStore.getState().setPreviewAsset("assets/photo.png", "project-a");
|
|
67
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/photo.png");
|
|
68
|
+
|
|
69
|
+
await renderNleProvider(root, { projectId: "project-b" });
|
|
70
|
+
|
|
71
|
+
// The overlay stays mounted across the project switch (EditorShell isn't
|
|
72
|
+
// keyed by projectId) — the store itself must be the thing that clears.
|
|
73
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
|
|
74
|
+
expect(useAssetPreviewStore.getState().previewProjectId).toBeNull();
|
|
75
|
+
|
|
76
|
+
act(() => root.unmount());
|
|
77
|
+
host.remove();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not clear the preview on a re-render that keeps the same projectId", async () => {
|
|
81
|
+
const { host, root } = await mountNleProvider({ projectId: "project-a" });
|
|
82
|
+
|
|
83
|
+
useAssetPreviewStore.getState().setPreviewAsset("assets/photo.png", "project-a");
|
|
84
|
+
|
|
85
|
+
await renderNleProvider(root, { projectId: "project-a", refreshKey: 1 });
|
|
86
|
+
|
|
87
|
+
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/photo.png");
|
|
88
|
+
|
|
89
|
+
act(() => root.unmount());
|
|
90
|
+
host.remove();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("NLEProvider — composition source-map scoping", () => {
|
|
95
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
96
|
+
|
|
97
|
+
it("clears the old map and ignores a late project-A response after switching to B", async () => {
|
|
98
|
+
let resolveA!: (response: Response) => void;
|
|
99
|
+
const fetchMock = vi.fn((url: string) => {
|
|
100
|
+
if (url.includes("project-a")) {
|
|
101
|
+
return new Promise<Response>((resolve) => {
|
|
102
|
+
resolveA = resolve;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return Promise.resolve({
|
|
106
|
+
ok: true,
|
|
107
|
+
json: async () => ({
|
|
108
|
+
content: '<div data-composition-id="b-comp" data-composition-src="compositions/b.html">',
|
|
109
|
+
}),
|
|
110
|
+
} as Response);
|
|
111
|
+
});
|
|
112
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
113
|
+
const observed: Array<Map<string, string>> = [];
|
|
114
|
+
const onChange = (map: Map<string, string>) => observed.push(new Map(map));
|
|
115
|
+
const { host, root } = await mountNleProvider({
|
|
116
|
+
projectId: "project-a",
|
|
117
|
+
onCompIdToSrcChange: onChange,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await renderNleProvider(root, {
|
|
121
|
+
projectId: "project-b",
|
|
122
|
+
onCompIdToSrcChange: onChange,
|
|
123
|
+
});
|
|
124
|
+
await act(async () => Promise.resolve());
|
|
125
|
+
|
|
126
|
+
expect(observed.filter((map) => map.size === 0)).toHaveLength(2);
|
|
127
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b/files/index.html", {
|
|
128
|
+
signal: expect.any(AbortSignal),
|
|
129
|
+
});
|
|
130
|
+
expect(observed.at(-1)).toEqual(new Map([["b-comp", "compositions/b.html"]]));
|
|
131
|
+
|
|
132
|
+
resolveA({
|
|
133
|
+
ok: true,
|
|
134
|
+
json: async () => ({
|
|
135
|
+
content: '<div data-composition-id="a-comp" data-composition-src="compositions/a.html">',
|
|
136
|
+
}),
|
|
137
|
+
} as Response);
|
|
138
|
+
await act(async () => Promise.resolve());
|
|
139
|
+
expect(observed.at(-1)).toEqual(new Map([["b-comp", "compositions/b.html"]]));
|
|
140
|
+
|
|
141
|
+
act(() => root.unmount());
|
|
142
|
+
host.remove();
|
|
143
|
+
});
|
|
144
|
+
});
|