@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
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
2
|
import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
|
|
3
3
|
|
|
4
|
+
/** The playback/selection state `reset()` restores (persistent prefs asserted separately). */
|
|
5
|
+
function expectResettableDefaults(state: ReturnType<typeof usePlayerStore.getState>): void {
|
|
6
|
+
expect(state.isPlaying).toBe(false);
|
|
7
|
+
expect(state.currentTime).toBe(0);
|
|
8
|
+
expect(state.duration).toBe(0);
|
|
9
|
+
expect(state.timelineReady).toBe(false);
|
|
10
|
+
expect(state.elements).toEqual([]);
|
|
11
|
+
expect(state.selectedElementId).toBeNull();
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
describe("usePlayerStore", () => {
|
|
5
15
|
beforeEach(() => {
|
|
6
16
|
usePlayerStore.getState().reset();
|
|
@@ -9,12 +19,7 @@ describe("usePlayerStore", () => {
|
|
|
9
19
|
describe("initial state", () => {
|
|
10
20
|
it("has correct defaults", () => {
|
|
11
21
|
const state = usePlayerStore.getState();
|
|
12
|
-
|
|
13
|
-
expect(state.currentTime).toBe(0);
|
|
14
|
-
expect(state.duration).toBe(0);
|
|
15
|
-
expect(state.timelineReady).toBe(false);
|
|
16
|
-
expect(state.elements).toEqual([]);
|
|
17
|
-
expect(state.selectedElementId).toBeNull();
|
|
22
|
+
expectResettableDefaults(state);
|
|
18
23
|
expect(state.playbackRate).toBe(1);
|
|
19
24
|
expect(state.audioMuted).toBe(false);
|
|
20
25
|
expect(state.loopEnabled).toBe(false);
|
|
@@ -384,6 +389,32 @@ describe("usePlayerStore", () => {
|
|
|
384
389
|
});
|
|
385
390
|
});
|
|
386
391
|
|
|
392
|
+
describe("clipRevealRequest", () => {
|
|
393
|
+
it("starts null and carries the requested element id", () => {
|
|
394
|
+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
|
395
|
+
usePlayerStore.getState().requestClipReveal("el-1");
|
|
396
|
+
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("el-1");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("bumps the nonce on repeat requests for the same clip", () => {
|
|
400
|
+
usePlayerStore.getState().requestClipReveal("el-1");
|
|
401
|
+
const first = usePlayerStore.getState().clipRevealRequest;
|
|
402
|
+
usePlayerStore.getState().requestClipReveal("el-1");
|
|
403
|
+
const second = usePlayerStore.getState().clipRevealRequest;
|
|
404
|
+
expect(second?.nonce).not.toBe(first?.nonce);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("clears via clearClipRevealRequest and on reset", () => {
|
|
408
|
+
usePlayerStore.getState().requestClipReveal("el-1");
|
|
409
|
+
usePlayerStore.getState().clearClipRevealRequest();
|
|
410
|
+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
|
411
|
+
|
|
412
|
+
usePlayerStore.getState().requestClipReveal("el-2");
|
|
413
|
+
usePlayerStore.getState().reset();
|
|
414
|
+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
387
418
|
describe("reset", () => {
|
|
388
419
|
it("resets all state to defaults", () => {
|
|
389
420
|
// Mutate everything
|
|
@@ -398,13 +429,7 @@ describe("usePlayerStore", () => {
|
|
|
398
429
|
// Reset
|
|
399
430
|
usePlayerStore.getState().reset();
|
|
400
431
|
|
|
401
|
-
|
|
402
|
-
expect(state.isPlaying).toBe(false);
|
|
403
|
-
expect(state.currentTime).toBe(0);
|
|
404
|
-
expect(state.duration).toBe(0);
|
|
405
|
-
expect(state.timelineReady).toBe(false);
|
|
406
|
-
expect(state.elements).toEqual([]);
|
|
407
|
-
expect(state.selectedElementId).toBeNull();
|
|
432
|
+
expectResettableDefaults(usePlayerStore.getState());
|
|
408
433
|
});
|
|
409
434
|
|
|
410
435
|
it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
|
|
@@ -3,6 +3,7 @@ import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
|
3
3
|
import type { BeatEditState } from "../../utils/beatEditing";
|
|
4
4
|
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
5
5
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
6
|
+
import { computePinnedZoomPercent } from "../components/timelineZoom";
|
|
6
7
|
|
|
7
8
|
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
|
|
8
9
|
export interface KeyframeCacheEntry {
|
|
@@ -28,6 +29,17 @@ export interface TimelineElement {
|
|
|
28
29
|
start: number;
|
|
29
30
|
duration: number;
|
|
30
31
|
track: number;
|
|
32
|
+
/**
|
|
33
|
+
* The data-track-index as written in the source file. Set at the manifest
|
|
34
|
+
* translation boundary (createTimelineElementFromManifestClip) from the
|
|
35
|
+
* runtime clip's verbatim track, and preserved through display-lane remaps
|
|
36
|
+
* (normalizeToZones packs sparse authored tracks onto contiguous display
|
|
37
|
+
* lanes; expanded sub-comp children get synthetic display rows). Lane edits
|
|
38
|
+
* must persist THIS space — writing a display-lane number into a sparse file
|
|
39
|
+
* re-targets the wrong track. For an expanded child the value is in its OWN
|
|
40
|
+
* source file's coordinate space, not the host timeline's.
|
|
41
|
+
*/
|
|
42
|
+
authoredTrack?: number;
|
|
31
43
|
/** Resolved z-index for stacking-aware timeline ordering. */
|
|
32
44
|
zIndex?: number;
|
|
33
45
|
/** True when the effective z-index was authored inline or through CSS, not auto. */
|
|
@@ -75,6 +87,10 @@ export interface TimelineElement {
|
|
|
75
87
|
export type ZoomMode = "fit" | "manual";
|
|
76
88
|
type TimelineTool = "select" | "razor";
|
|
77
89
|
|
|
90
|
+
export interface SelectElementOptions {
|
|
91
|
+
preserveSet?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
function resolveElementSelection(
|
|
79
95
|
ids: Iterable<string>,
|
|
80
96
|
anchor?: string | null,
|
|
@@ -142,6 +158,28 @@ interface PlayerState {
|
|
|
142
158
|
|
|
143
159
|
/** Multi-select: additional selected elements beyond selectedElementId. */
|
|
144
160
|
selectedElementIds: Set<string>;
|
|
161
|
+
clearSelectedElementIds: () => void;
|
|
162
|
+
/** Replace the whole multi-selection at once (marquee live updates). */
|
|
163
|
+
setSelectedElementIds: (ids: Set<string>) => void;
|
|
164
|
+
/** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
|
|
165
|
+
timelineSnapEnabled: boolean;
|
|
166
|
+
setTimelineSnapEnabled: (enabled: boolean) => void;
|
|
167
|
+
/** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
|
|
168
|
+
timeDisplayMode: "time" | "frame";
|
|
169
|
+
setTimeDisplayMode: (mode: "time" | "frame") => void;
|
|
170
|
+
/**
|
|
171
|
+
* Pin the timeline zoom to its current visual scale before a duration-changing
|
|
172
|
+
* edit, so a subsequent duration change (which recomputes fit-pps) stops
|
|
173
|
+
* rescaling every clip. No-op once already pinned (mode is "manual").
|
|
174
|
+
*/
|
|
175
|
+
pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
|
|
176
|
+
/**
|
|
177
|
+
* The timeline's live pixels-per-second + fit basis, published by <Timeline> on
|
|
178
|
+
* every render. Non-reactive scratch state (never read as a render input).
|
|
179
|
+
*/
|
|
180
|
+
timelinePps: number;
|
|
181
|
+
timelineFitPps: number;
|
|
182
|
+
setTimelineScale: (pps: number, fitPps: number) => void;
|
|
145
183
|
setSelection: (ids: Iterable<string>, anchor?: string | null) => void;
|
|
146
184
|
addSelectedElementId: (id: string) => void;
|
|
147
185
|
toggleSelectedElementId: (id: string) => void;
|
|
@@ -160,7 +198,7 @@ interface PlayerState {
|
|
|
160
198
|
setTimelineReady: (ready: boolean) => void;
|
|
161
199
|
setBeatDragging: (dragging: boolean) => void;
|
|
162
200
|
setElements: (elements: TimelineElement[]) => void;
|
|
163
|
-
setSelectedElementId: (id: string | null) => void;
|
|
201
|
+
setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void;
|
|
164
202
|
/** Move the selection anchor within an active multi-selection without collapsing it. */
|
|
165
203
|
setSelectionAnchor: (id: string | null) => void;
|
|
166
204
|
updateElement: (
|
|
@@ -186,6 +224,16 @@ interface PlayerState {
|
|
|
186
224
|
requestSeek: (time: number) => void;
|
|
187
225
|
clearSeekRequest: () => void;
|
|
188
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Request the timeline to scroll a clip into view (e.g. clicking an
|
|
229
|
+
* already-added asset card in the sidebar). Consumed and cleared by
|
|
230
|
+
* useTimelineRevealClip. The nonce makes repeat requests for the same
|
|
231
|
+
* clip observable so a second click re-reveals after the user scrolls away.
|
|
232
|
+
*/
|
|
233
|
+
clipRevealRequest: { elementId: string; nonce: number } | null;
|
|
234
|
+
requestClipReveal: (elementId: string) => void;
|
|
235
|
+
clearClipRevealRequest: () => void;
|
|
236
|
+
|
|
189
237
|
lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
|
|
190
238
|
setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
|
|
191
239
|
|
|
@@ -260,6 +308,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
260
308
|
loopEnabled: false,
|
|
261
309
|
zoomMode: "fit",
|
|
262
310
|
manualZoomPercent: 100,
|
|
311
|
+
timelinePps: 100,
|
|
312
|
+
timelineFitPps: 100,
|
|
263
313
|
inPoint: null,
|
|
264
314
|
outPoint: null,
|
|
265
315
|
|
|
@@ -315,6 +365,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
315
365
|
requestSeek: (time) => set({ requestedSeekTime: time }),
|
|
316
366
|
clearSeekRequest: () => set({ requestedSeekTime: null }),
|
|
317
367
|
|
|
368
|
+
clipRevealRequest: null,
|
|
369
|
+
requestClipReveal: (elementId) =>
|
|
370
|
+
set((s) => ({
|
|
371
|
+
clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
|
|
372
|
+
})),
|
|
373
|
+
clearClipRevealRequest: () => set({ clipRevealRequest: null }),
|
|
374
|
+
|
|
318
375
|
lintFindingsByElement: new Map(),
|
|
319
376
|
setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
|
|
320
377
|
|
|
@@ -383,6 +440,37 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
383
440
|
},
|
|
384
441
|
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
|
|
385
442
|
setZoomMode: (mode) => set({ zoomMode: mode }),
|
|
443
|
+
clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
|
|
444
|
+
setSelectedElementIds: (ids: Set<string>) => set({ selectedElementIds: new Set(ids) }),
|
|
445
|
+
timelineSnapEnabled: readStudioUiPreferences().timelineSnapEnabled ?? true,
|
|
446
|
+
setTimelineSnapEnabled: (enabled) => {
|
|
447
|
+
writeStudioUiPreferences({ timelineSnapEnabled: enabled });
|
|
448
|
+
set({ timelineSnapEnabled: enabled });
|
|
449
|
+
},
|
|
450
|
+
timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
|
|
451
|
+
setTimeDisplayMode: (mode) => {
|
|
452
|
+
writeStudioUiPreferences({ timeDisplayMode: mode });
|
|
453
|
+
set({ timeDisplayMode: mode });
|
|
454
|
+
},
|
|
455
|
+
pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
|
|
456
|
+
set((s) => {
|
|
457
|
+
// Already pinned (or the user manually zoomed) — never clobber that.
|
|
458
|
+
if (s.zoomMode === "manual") return {};
|
|
459
|
+
const percent = computePinnedZoomPercent(currentPixelsPerSecond, fitPixelsPerSecond);
|
|
460
|
+
writeStudioUiPreferences({
|
|
461
|
+
timelineZoomMode: "manual",
|
|
462
|
+
timelineManualZoomPercent: percent,
|
|
463
|
+
});
|
|
464
|
+
return { zoomMode: "manual", manualZoomPercent: percent };
|
|
465
|
+
}),
|
|
466
|
+
setTimelineScale: (pps, fitPps) => {
|
|
467
|
+
// Non-reactive publish: mutate in place + reuse the same object identity so no
|
|
468
|
+
// subscriber re-renders (these fields are never a render input, only read
|
|
469
|
+
// imperatively before pinning).
|
|
470
|
+
const state = get();
|
|
471
|
+
state.timelinePps = pps;
|
|
472
|
+
state.timelineFitPps = fitPps;
|
|
473
|
+
},
|
|
386
474
|
setInPoint: (time) =>
|
|
387
475
|
set((state) => {
|
|
388
476
|
const t = time !== null && Number.isFinite(time) ? time : null;
|
|
@@ -414,9 +502,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
414
502
|
// A genuine single selection: always collapse the set to just this element. User
|
|
415
503
|
// intent (timeline click, preview click via applyDomSelection) flows here; DOM sync
|
|
416
504
|
// echoes that must preserve a group go through setSelectionAnchor instead.
|
|
417
|
-
setSelectedElementId: (id) =>
|
|
505
|
+
setSelectedElementId: (id, options) =>
|
|
418
506
|
set((s) => {
|
|
419
|
-
const
|
|
507
|
+
const preserveSet = Boolean(options?.preserveSet && id && s.selectedElementIds.has(id));
|
|
508
|
+
const selectedElementIds = preserveSet
|
|
509
|
+
? new Set(s.selectedElementIds)
|
|
510
|
+
: options?.preserveSet
|
|
511
|
+
? new Set<string>()
|
|
512
|
+
: id
|
|
513
|
+
? new Set([id])
|
|
514
|
+
: new Set<string>();
|
|
420
515
|
// Selecting a different element drops any active keyframe selection — otherwise
|
|
421
516
|
// a stale activeKeyframePct from a prior diamond click would force the next drag
|
|
422
517
|
// to "modify" a keyframe on the new element. A diamond click sets the pct AFTER
|
|
@@ -463,6 +558,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
463
558
|
activeTool: "select",
|
|
464
559
|
selectedKeyframes: new Set(),
|
|
465
560
|
selectedElementIds: new Set(),
|
|
561
|
+
clipRevealRequest: null,
|
|
466
562
|
keyframeCache: new Map(),
|
|
467
563
|
beatAnalysis: null,
|
|
468
564
|
beatEdits: null,
|
package/src/styles/studio.css
CHANGED
|
@@ -141,6 +141,22 @@ body {
|
|
|
141
141
|
opacity: 1;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/* Audio clips read as a distinct kind — a persistent violet tint vs the teal-
|
|
145
|
+
accented visual clips — so the audio zone at the bottom is visually separate
|
|
146
|
+
at a glance (the timeline's kinds: visual on top, audio below). */
|
|
147
|
+
.timeline-clip.is-audio {
|
|
148
|
+
background-color: rgba(167, 139, 250, 0.16);
|
|
149
|
+
border-color: rgba(167, 139, 250, 0.4);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.timeline-clip.is-audio.is-hovered {
|
|
153
|
+
background-color: rgba(167, 139, 250, 0.24);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.timeline-clip.is-audio.is-dragging {
|
|
157
|
+
background-color: rgba(60, 52, 84, 0.96);
|
|
158
|
+
}
|
|
159
|
+
|
|
144
160
|
.timeline-clip.is-hovered {
|
|
145
161
|
background-color: rgba(255, 255, 255, 0.09);
|
|
146
162
|
}
|
|
@@ -158,9 +174,20 @@ body {
|
|
|
158
174
|
}
|
|
159
175
|
|
|
160
176
|
.timeline-clip.is-dragging {
|
|
177
|
+
/* Solid background so the picked-up clip reads clearly while dragging — audio
|
|
178
|
+
clips (waveform drawn on a separate layer) would otherwise look transparent. */
|
|
179
|
+
background-color: rgba(38, 42, 52, 0.96);
|
|
161
180
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
162
181
|
}
|
|
163
182
|
|
|
183
|
+
/* Keep the white selection outline while dragging (a plain box-shadow alone gets
|
|
184
|
+
overridden by .is-dragging's drop shadow, so re-state both together). */
|
|
185
|
+
.timeline-clip.is-selected.is-dragging {
|
|
186
|
+
box-shadow:
|
|
187
|
+
0 0 0 1.5px rgba(255, 255, 255, 0.85),
|
|
188
|
+
0 8px 24px rgba(0, 0, 0, 0.4);
|
|
189
|
+
}
|
|
190
|
+
|
|
164
191
|
.timeline-clip[data-active].is-selected.is-dragging {
|
|
165
192
|
box-shadow:
|
|
166
193
|
0 0 0 1.5px rgba(255, 255, 255, 0.85),
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { shouldDismissAssetPreview } from "./assetPreviewDismiss";
|
|
3
|
+
|
|
4
|
+
describe("shouldDismissAssetPreview", () => {
|
|
5
|
+
const idle = { isPlaying: false, currentTime: 3.5, requestedSeekTime: null };
|
|
6
|
+
|
|
7
|
+
it("keeps the preview open while nothing moves", () => {
|
|
8
|
+
expect(shouldDismissAssetPreview(3.5, idle)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("tolerates sub-epsilon float noise in currentTime echoes", () => {
|
|
12
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 3.5 + 1e-9 })).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("dismisses when playback starts", () => {
|
|
16
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, isPlaying: true })).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("dismisses when the playhead is scrubbed/seeked to a new time", () => {
|
|
20
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 4.2 })).toBe(true);
|
|
21
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 0 })).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("dismisses on a pending out-of-loop seek request", () => {
|
|
25
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, requestedSeekTime: 3.5 })).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dismissal rule for the sidebar asset preview overlay (AssetPreviewOverlay):
|
|
3
|
+
* the preview is a transient "look at this asset" state, so any playhead
|
|
4
|
+
* activity — starting playback, or seeking/scrubbing away from where the
|
|
5
|
+
* playhead sat when the preview opened — hands focus back to the canvas and
|
|
6
|
+
* closes it.
|
|
7
|
+
*
|
|
8
|
+
* Pure — unit-tested. The overlay captures `openedTime` when the preview
|
|
9
|
+
* opens and feeds every subsequent player-store snapshot through this.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface AssetPreviewDismissSnapshot {
|
|
13
|
+
isPlaying: boolean;
|
|
14
|
+
currentTime: number;
|
|
15
|
+
/** Pending out-of-loop seek request (playerStore.requestedSeekTime). */
|
|
16
|
+
requestedSeekTime: number | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Tolerance for float noise in currentTime echoes (well under one frame). */
|
|
20
|
+
const TIME_EPSILON_S = 1e-6;
|
|
21
|
+
|
|
22
|
+
export function shouldDismissAssetPreview(
|
|
23
|
+
openedTime: number,
|
|
24
|
+
snapshot: AssetPreviewDismissSnapshot,
|
|
25
|
+
): boolean {
|
|
26
|
+
if (snapshot.isPlaying) return true;
|
|
27
|
+
if (snapshot.requestedSeekTime !== null) return true;
|
|
28
|
+
return Math.abs(snapshot.currentTime - openedTime) > TIME_EPSILON_S;
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny Zustand slice that carries the "asset preview overlay" state.
|
|
3
|
+
*
|
|
4
|
+
* When a user clicks an asset card that has NOT yet been added to the
|
|
5
|
+
* timeline the overlay fires up: a dark scrim + centered media element
|
|
6
|
+
* (img / video / audio) + filename label rendered inside PreviewPane.
|
|
7
|
+
*
|
|
8
|
+
* State lives here so AssetsTab (sidebar) and PreviewPane (preview column)
|
|
9
|
+
* can communicate without prop-drilling through the multi-layer EditorShell
|
|
10
|
+
* tree. The store is project-scoped: NLEProvider (NLEContext.tsx) clears it
|
|
11
|
+
* whenever `projectId` changes, so a preview opened in one project can't
|
|
12
|
+
* bleed into another (the overlay itself stays mounted across project
|
|
13
|
+
* switches — EditorShell isn't keyed by projectId).
|
|
14
|
+
*/
|
|
15
|
+
import { create } from "zustand";
|
|
16
|
+
|
|
17
|
+
interface AssetPreviewState {
|
|
18
|
+
/** Project-relative asset path currently being previewed, or null. */
|
|
19
|
+
previewAsset: string | null;
|
|
20
|
+
/** projectId for which the preview was opened (used to build the serve URL). */
|
|
21
|
+
previewProjectId: string | null;
|
|
22
|
+
/** Open a media preview for the given asset. */
|
|
23
|
+
setPreviewAsset: (asset: string, projectId: string) => void;
|
|
24
|
+
/** Close the preview overlay. */
|
|
25
|
+
clearPreviewAsset: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const useAssetPreviewStore = create<AssetPreviewState>((set) => ({
|
|
29
|
+
previewAsset: null,
|
|
30
|
+
previewProjectId: null,
|
|
31
|
+
setPreviewAsset: (asset, projectId) => set({ previewAsset: asset, previewProjectId: projectId }),
|
|
32
|
+
clearPreviewAsset: () => set({ previewAsset: null, previewProjectId: null }),
|
|
33
|
+
}));
|
|
@@ -2,9 +2,10 @@ import type { RegistryItem } from "@hyperframes/core/registry";
|
|
|
2
2
|
import type { TimelineElement } from "../player";
|
|
3
3
|
import {
|
|
4
4
|
insertTimelineAssetIntoSource,
|
|
5
|
-
|
|
5
|
+
resolveTimelineAssetCompositionSize,
|
|
6
6
|
} from "./timelineAssetDrop";
|
|
7
7
|
import { collectHtmlIds } from "./studioHelpers";
|
|
8
|
+
import { generateId } from "./generateId";
|
|
8
9
|
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
|
9
10
|
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
|
10
11
|
import type { EditHistoryKind } from "./editHistory";
|
|
@@ -120,7 +121,9 @@ export async function addBlockToProject(
|
|
|
120
121
|
);
|
|
121
122
|
|
|
122
123
|
const isBlock = block.type === "hyperframes:block";
|
|
123
|
-
const
|
|
124
|
+
const { width: hostWidth, height: hostHeight } =
|
|
125
|
+
resolveTimelineAssetCompositionSize(originalContent);
|
|
126
|
+
const hostDims = { left: 0, top: 0, width: hostWidth, height: hostHeight };
|
|
124
127
|
|
|
125
128
|
const currentTime = opts.currentTime ?? 0;
|
|
126
129
|
const start = placement
|
|
@@ -152,6 +155,11 @@ export async function addBlockToProject(
|
|
|
152
155
|
|
|
153
156
|
const subCompHtml = [
|
|
154
157
|
`<div`,
|
|
158
|
+
// A stable id (+ hf-id) is what authored sub-comps carry; without it the
|
|
159
|
+
// timeline can't dedup the host and renders duplicate clips that multiply
|
|
160
|
+
// on every interaction. Matches the authored-comp shape.
|
|
161
|
+
` id="${compId}"`,
|
|
162
|
+
` data-hf-id="hf-${generateId()}"`,
|
|
155
163
|
` data-composition-id="${compId}"`,
|
|
156
164
|
` data-composition-src="${compositionFile}"`,
|
|
157
165
|
` data-start="${formatTimelineAttributeNumber(start)}"`,
|
|
@@ -209,6 +209,41 @@ describe("edit history", () => {
|
|
|
209
209
|
expect(state.undo[0].files["index.html"].after).toBe("c");
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
it("merges a lane-change move with its z-reorder past the default window via entry coalesceMs", () => {
|
|
213
|
+
// The z entry records only after the move persist's round-trip — often >300ms.
|
|
214
|
+
// Both sides pass coalesceMs: 5000 with the shared gesture key so the pair
|
|
215
|
+
// still folds into ONE undo step.
|
|
216
|
+
const move = buildEditHistoryEntry({
|
|
217
|
+
projectId: "project-1",
|
|
218
|
+
label: "Move timeline clips",
|
|
219
|
+
kind: "timeline",
|
|
220
|
+
coalesceKey: "clip-lane-move:1",
|
|
221
|
+
coalesceMs: 5000,
|
|
222
|
+
files: { "index.html": { before: "a", after: "b" } },
|
|
223
|
+
now: 100,
|
|
224
|
+
id: "move-entry",
|
|
225
|
+
});
|
|
226
|
+
const zReorder = buildEditHistoryEntry({
|
|
227
|
+
projectId: "project-1",
|
|
228
|
+
label: "Reorder layers",
|
|
229
|
+
kind: "manual",
|
|
230
|
+
coalesceKey: "clip-lane-move:1",
|
|
231
|
+
coalesceMs: 5000,
|
|
232
|
+
files: { "index.html": { before: "b", after: "c" } },
|
|
233
|
+
now: 500,
|
|
234
|
+
id: "z-entry",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const state = pushEditHistoryEntry(
|
|
238
|
+
pushEditHistoryEntry(createEmptyEditHistory(), move),
|
|
239
|
+
zReorder,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(state.undo).toHaveLength(1);
|
|
243
|
+
expect(state.undo[0].files["index.html"].before).toBe("a");
|
|
244
|
+
expect(state.undo[0].files["index.html"].after).toBe("c");
|
|
245
|
+
});
|
|
246
|
+
|
|
212
247
|
it("folds a slow GSAP follow-up into the timing edit via a per-entry coalesceMs override", () => {
|
|
213
248
|
const timing = buildEditHistoryEntry({
|
|
214
249
|
projectId: "project-1",
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi } from "vitest";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
applySoftReload,
|
|
6
|
+
ensureMotionPathPluginLoaded,
|
|
7
|
+
diffSoftReloadableRestore,
|
|
8
|
+
applyUndoRestoreToPreview,
|
|
9
|
+
} from "./gsapSoftReload";
|
|
5
10
|
|
|
6
11
|
const SCRIPT_TEXT = `
|
|
7
12
|
window.__timelines = window.__timelines || {};
|
|
@@ -447,3 +452,116 @@ describe("applySoftReload authored-opacity restore", () => {
|
|
|
447
452
|
expect(restoreOpacity(el)).toBe("");
|
|
448
453
|
});
|
|
449
454
|
});
|
|
455
|
+
|
|
456
|
+
// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
const wrap = (body: string) => `<html><body>${body}</body></html>`;
|
|
459
|
+
|
|
460
|
+
describe("diffSoftReloadableRestore", () => {
|
|
461
|
+
it("reports the changed id for an attribute/inline-style-only diff", () => {
|
|
462
|
+
const prev = wrap(`<div id="a" style="translate: 10px 10px">t</div>`);
|
|
463
|
+
const next = wrap(`<div id="a" style="translate: 0px 0px">t</div>`);
|
|
464
|
+
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: ["a"] });
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("treats a structural change (added element) as NOT soft-reloadable", () => {
|
|
468
|
+
const prev = wrap(`<div id="a">t</div>`);
|
|
469
|
+
const next = wrap(`<div id="a">t</div><div id="a-split">t</div>`);
|
|
470
|
+
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("treats an element text/child change as NOT soft-reloadable", () => {
|
|
474
|
+
const prev = wrap(`<div id="a">one</div>`);
|
|
475
|
+
const next = wrap(`<div id="a">two</div>`);
|
|
476
|
+
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("allows a GSAP-script-only change (no id'd-attribute diff)", () => {
|
|
480
|
+
const prev = wrap(
|
|
481
|
+
`<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
|
|
482
|
+
);
|
|
483
|
+
const next = wrap(
|
|
484
|
+
`<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:9});</script>`,
|
|
485
|
+
);
|
|
486
|
+
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: [] });
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
function buildLiveIframe(bodyHtml: string) {
|
|
491
|
+
const doc = document.implementation.createHTMLDocument("");
|
|
492
|
+
doc.body.innerHTML = bodyHtml;
|
|
493
|
+
const contentWindow = {
|
|
494
|
+
gsap: { timeline: () => {} },
|
|
495
|
+
__hfForceTimelineRebind: () => {},
|
|
496
|
+
__timelines: {} as Record<string, unknown>,
|
|
497
|
+
__player: { getTime: () => 3, seek: vi.fn() },
|
|
498
|
+
__hfStudioManualEditsApply: vi.fn(),
|
|
499
|
+
};
|
|
500
|
+
return {
|
|
501
|
+
iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
|
|
502
|
+
contentWindow,
|
|
503
|
+
doc,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
describe("applyUndoRestoreToPreview", () => {
|
|
508
|
+
const ROOT = "index.html";
|
|
509
|
+
|
|
510
|
+
it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
|
|
511
|
+
const { iframe, contentWindow, doc } = buildLiveIframe(
|
|
512
|
+
`<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
|
|
513
|
+
);
|
|
514
|
+
const reloadPreview = vi.fn();
|
|
515
|
+
const files = {
|
|
516
|
+
[ROOT]: {
|
|
517
|
+
previous: wrap(
|
|
518
|
+
`<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
|
|
519
|
+
),
|
|
520
|
+
restored: wrap(`<div id="a" style="translate: 0px 0px" data-hf-path-offset="true">t</div>`),
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview);
|
|
524
|
+
expect(outcome).toBe("soft");
|
|
525
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
526
|
+
// Live element reverted to the restored inline style.
|
|
527
|
+
expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px");
|
|
528
|
+
// No GSAP script in the restore → the manual-edit reapply runs, playhead held.
|
|
529
|
+
expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
|
|
530
|
+
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("full-reloads a multi-file restore", () => {
|
|
534
|
+
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
535
|
+
const reloadPreview = vi.fn();
|
|
536
|
+
const files = {
|
|
537
|
+
[ROOT]: {
|
|
538
|
+
previous: wrap(`<div id="a" style="x">t</div>`),
|
|
539
|
+
restored: wrap(`<div id="a">t</div>`),
|
|
540
|
+
},
|
|
541
|
+
"scenes/intro.html": { previous: "a", restored: "b" },
|
|
542
|
+
};
|
|
543
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
544
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("full-reloads a structural restore (split/delete undo)", () => {
|
|
548
|
+
const { iframe } = buildLiveIframe(`<div id="a">t</div><div id="a-split">t</div>`);
|
|
549
|
+
const reloadPreview = vi.fn();
|
|
550
|
+
const files = {
|
|
551
|
+
[ROOT]: {
|
|
552
|
+
previous: wrap(`<div id="a">t</div><div id="a-split">t</div>`),
|
|
553
|
+
restored: wrap(`<div id="a">t</div>`),
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
557
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
|
|
561
|
+
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
562
|
+
const reloadPreview = vi.fn();
|
|
563
|
+
const files = { "scenes/intro.html": { previous: "a", restored: "b" } };
|
|
564
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
565
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
566
|
+
});
|
|
567
|
+
});
|