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