@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
|
@@ -24,10 +24,11 @@ import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
|
24
24
|
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
|
|
25
25
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
26
26
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
27
|
-
import { selectorFromSelection } from "./gsapShared";
|
|
27
|
+
import { isInstantHold, selectorFromSelection } from "./gsapShared";
|
|
28
28
|
import { roundTo3 } from "../utils/rounding";
|
|
29
29
|
import { resolveGroupTween, POSITION_CHANNELS } from "./gsapRuntimeBridge";
|
|
30
30
|
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
31
|
+
import { logResize } from "../utils/resizeDebug";
|
|
31
32
|
|
|
32
33
|
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
|
|
33
34
|
|
|
@@ -68,19 +69,27 @@ export async function tryGsapResizeIntercept(
|
|
|
68
69
|
);
|
|
69
70
|
|
|
70
71
|
let anim = resolved?.anim ?? null;
|
|
71
|
-
|
|
72
|
+
logResize("intercept-enter", {
|
|
73
|
+
hasScaleGroup,
|
|
74
|
+
resizeGroup,
|
|
75
|
+
animMethod: anim?.method ?? null,
|
|
76
|
+
animId: anim?.id ?? null,
|
|
77
|
+
size,
|
|
78
|
+
});
|
|
79
|
+
if (!anim || isInstantHold(anim)) {
|
|
72
80
|
const sel = selectorFromSelection(selection);
|
|
73
81
|
if (!sel) return false;
|
|
74
|
-
const sizeSet = anim
|
|
82
|
+
const sizeSet = anim ?? findSizeSetAnimation(animations, sel);
|
|
75
83
|
|
|
76
84
|
// If the element is animated (has a real tween, not just a static size
|
|
77
85
|
// hold), keyframe the size at the playhead so other keyframes keep theirs —
|
|
78
86
|
// instead of a global set that resizes every frame.
|
|
79
87
|
if (resizeGroup === "size") {
|
|
80
88
|
const animatedTween = pickClosestToPlayhead(
|
|
81
|
-
animations.filter((a) => a
|
|
89
|
+
animations.filter((a) => !isInstantHold(a) && resolveTweenDuration(a) > 0),
|
|
82
90
|
);
|
|
83
91
|
if (animatedTween) {
|
|
92
|
+
logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
|
|
84
93
|
const handled = await commitKeyframedSizeFromResize(
|
|
85
94
|
selection,
|
|
86
95
|
size,
|
|
@@ -93,6 +102,7 @@ export async function tryGsapResizeIntercept(
|
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
logResize("intercept-route", { route: "static-size-set", hadSizeSet: !!sizeSet, resizeGroup });
|
|
96
106
|
await commitStaticGsapSize(selection, size, sel, sizeSet, {
|
|
97
107
|
commitMutation,
|
|
98
108
|
fetchAnimations: fetchFallbackAnimations,
|
|
@@ -100,11 +110,12 @@ export async function tryGsapResizeIntercept(
|
|
|
100
110
|
return true;
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
const tweenDuration = resolveTweenDuration(anim);
|
|
114
|
+
if (tweenDuration <= 0) return false;
|
|
115
|
+
|
|
103
116
|
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
104
117
|
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
105
118
|
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
|
106
|
-
const coalesceKey = `gsap:resize:${anim.id}`;
|
|
107
|
-
|
|
108
119
|
const selector = selectorFromSelection(selection);
|
|
109
120
|
// Scope every capture to the resize group — same contract as the rotation
|
|
110
121
|
// intercept. Unfiltered, an opacity-touching intro tween on the element
|
|
@@ -144,6 +155,16 @@ export async function tryGsapResizeIntercept(
|
|
|
144
155
|
// shorthand when the two agree (aspect-true drags, shift-drags).
|
|
145
156
|
nonUniformScale = Math.abs(newScaleX - newScaleY) > 0.01;
|
|
146
157
|
resizeProps = nonUniformScale ? { scaleX: newScaleX, scaleY: newScaleY } : { scale: newScaleX };
|
|
158
|
+
logResize("intercept-route", {
|
|
159
|
+
route: "scale-tween",
|
|
160
|
+
cssW,
|
|
161
|
+
cssH,
|
|
162
|
+
liveScaleX,
|
|
163
|
+
liveScaleY,
|
|
164
|
+
newScaleX,
|
|
165
|
+
newScaleY,
|
|
166
|
+
nonUniformScale,
|
|
167
|
+
});
|
|
147
168
|
scaleDraftEl = el;
|
|
148
169
|
// Where the user DROPPED the box: the draft (anchor-pinned to the
|
|
149
170
|
// gesture-start top-left) is still applied here, so this rect is exactly
|
|
@@ -184,6 +205,7 @@ export async function tryGsapResizeIntercept(
|
|
|
184
205
|
POSITION_CHANNELS,
|
|
185
206
|
);
|
|
186
207
|
if (hasLivePositionTween) {
|
|
208
|
+
logResize("scale-finalize", { skipped: "live-position-tween" });
|
|
187
209
|
return;
|
|
188
210
|
}
|
|
189
211
|
// The scale commit has rendered (instant patch or soft-reload seek) and the
|
|
@@ -200,6 +222,13 @@ export async function tryGsapResizeIntercept(
|
|
|
200
222
|
x: Math.round(gsapPos.x + residual.x),
|
|
201
223
|
y: Math.round(gsapPos.y + residual.y),
|
|
202
224
|
};
|
|
225
|
+
logResize("scale-finalize", {
|
|
226
|
+
dropPoint: scaleDraftDropPoint,
|
|
227
|
+
post: { x: post.x, y: post.y },
|
|
228
|
+
residual,
|
|
229
|
+
gsapPos,
|
|
230
|
+
corrected,
|
|
231
|
+
});
|
|
203
232
|
// Correct the LIVE runtime NOW, synchronously: the soft reload above just
|
|
204
233
|
// rendered the committed scale around the element center — NOT at the drop
|
|
205
234
|
// point — and everything up to here runs in the same microtask chain as
|
|
@@ -249,7 +278,7 @@ export async function tryGsapResizeIntercept(
|
|
|
249
278
|
|
|
250
279
|
const ct = usePlayerStore.getState().currentTime;
|
|
251
280
|
const ts = resolveTweenStart(anim);
|
|
252
|
-
const td =
|
|
281
|
+
const td = tweenDuration;
|
|
253
282
|
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
|
|
254
283
|
// Outside-range uses the extend path which handles everything atomically.
|
|
255
284
|
if (!outsideRange) {
|
|
@@ -264,7 +293,7 @@ export async function tryGsapResizeIntercept(
|
|
|
264
293
|
await commitMutation(
|
|
265
294
|
selection,
|
|
266
295
|
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
|
|
267
|
-
{ label: "Convert to keyframes for resize"
|
|
296
|
+
{ label: "Convert to keyframes for resize" },
|
|
268
297
|
);
|
|
269
298
|
if (fetchFallbackAnimations) {
|
|
270
299
|
const fresh = await fetchFallbackAnimations();
|
|
@@ -353,7 +382,6 @@ export async function tryGsapResizeIntercept(
|
|
|
353
382
|
? `Resize (extended to ${ct.toFixed(2)}s)`
|
|
354
383
|
: `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`,
|
|
355
384
|
softReload: true,
|
|
356
|
-
coalesceKey,
|
|
357
385
|
},
|
|
358
386
|
);
|
|
359
387
|
await finalizeScaleResizeCommit();
|
|
@@ -376,7 +404,7 @@ export async function tryGsapResizeIntercept(
|
|
|
376
404
|
properties: resizeProps,
|
|
377
405
|
backfillDefaults,
|
|
378
406
|
},
|
|
379
|
-
{ label: `Resize (keyframe ${pct}%)`, softReload: true
|
|
407
|
+
{ label: `Resize (keyframe ${pct}%)`, softReload: true },
|
|
380
408
|
);
|
|
381
409
|
await finalizeScaleResizeCommit();
|
|
382
410
|
return true;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
|
-
import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
|
|
4
|
+
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
|
|
5
5
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -90,7 +90,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
|
|
90
90
|
expect(mutation.type).not.toBe("add-keyframe");
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
-
it("forwards
|
|
93
|
+
it("forwards one complete instantPatch when atomically updating an existing static set", async () => {
|
|
94
94
|
const commitMutation = vi.fn();
|
|
95
95
|
const iframe = fakeIframe("puck-b", []); // runtime empty → STATIC path
|
|
96
96
|
// An existing position-hold `set` for the selector → update-in-place (not add).
|
|
@@ -113,16 +113,14 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
|
|
113
113
|
);
|
|
114
114
|
|
|
115
115
|
expect(handled).toBe(true);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
selector: "#puck-b",
|
|
123
|
-
change: { kind: "set", props: { x: -50 } },
|
|
116
|
+
const updates = commitMutation.mock.calls.filter(([, m]) => m.type === "update-properties");
|
|
117
|
+
expect(updates).toHaveLength(1);
|
|
118
|
+
expect(updates[0][1]).toEqual({
|
|
119
|
+
type: "update-properties",
|
|
120
|
+
animationId: "#puck-b-set",
|
|
121
|
+
properties: { x: -50, y: 30 },
|
|
124
122
|
});
|
|
125
|
-
expect(updates[
|
|
123
|
+
expect(updates[0][2].instantPatch).toEqual({
|
|
126
124
|
selector: "#puck-b",
|
|
127
125
|
change: { kind: "set", props: { x: -50, y: 30 } },
|
|
128
126
|
});
|
|
@@ -154,9 +152,9 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
|
|
154
152
|
);
|
|
155
153
|
|
|
156
154
|
expect(handled).toBe(true);
|
|
157
|
-
//
|
|
155
|
+
// One atomic in-place update, NOT an `add`/`add-keyframe`.
|
|
158
156
|
const types = commitMutation.mock.calls.map(([, m]) => m.type);
|
|
159
|
-
expect(types.
|
|
157
|
+
expect(types).toEqual(["update-properties"]);
|
|
160
158
|
expect(types).not.toContain("add");
|
|
161
159
|
expect(types).not.toContain("add-keyframe");
|
|
162
160
|
});
|
|
@@ -180,6 +178,44 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
|
|
180
178
|
});
|
|
181
179
|
});
|
|
182
180
|
|
|
181
|
+
describe("tryGsapRotationIntercept — instant holds", () => {
|
|
182
|
+
it("updates a duration-zero fromTo hold instead of converting it to keyframes", async () => {
|
|
183
|
+
const rotationHold = {
|
|
184
|
+
id: "#puck-b-fromTo-0-rotation",
|
|
185
|
+
targetSelector: "#puck-b",
|
|
186
|
+
propertyGroup: "rotation",
|
|
187
|
+
method: "fromTo",
|
|
188
|
+
fromProperties: { rotation: 0 },
|
|
189
|
+
properties: { rotation: 30 },
|
|
190
|
+
position: 0,
|
|
191
|
+
resolvedStart: 0,
|
|
192
|
+
duration: 0,
|
|
193
|
+
} as unknown as GsapAnimation;
|
|
194
|
+
const commitMutation = vi.fn();
|
|
195
|
+
|
|
196
|
+
const handled = await tryGsapRotationIntercept(
|
|
197
|
+
selection,
|
|
198
|
+
75,
|
|
199
|
+
[rotationHold],
|
|
200
|
+
null,
|
|
201
|
+
commitMutation,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(handled).toBe(true);
|
|
205
|
+
expect(commitMutation).toHaveBeenCalledTimes(1);
|
|
206
|
+
expect(commitMutation.mock.calls[0]![1]).toEqual({
|
|
207
|
+
type: "update-property",
|
|
208
|
+
animationId: rotationHold.id,
|
|
209
|
+
property: "rotation",
|
|
210
|
+
value: 75,
|
|
211
|
+
});
|
|
212
|
+
const types = commitMutation.mock.calls.map(([, mutation]) => mutation.type);
|
|
213
|
+
expect(types).not.toContain("convert-to-keyframes");
|
|
214
|
+
expect(types).not.toContain("add-keyframe");
|
|
215
|
+
expect(types).not.toContain("add");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
183
219
|
// Regression (#1808): with the global auto-keyframe toggle off, dragging an
|
|
184
220
|
// element that already has a keyframed position tween must shift the whole
|
|
185
221
|
// tween (a "replace-with-keyframes" carrying every original percentage) —
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
27
27
|
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
28
28
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
29
|
-
import { selectorFromSelection } from "./gsapShared";
|
|
29
|
+
import { isInstantHold, selectorFromSelection } from "./gsapShared";
|
|
30
30
|
import {
|
|
31
31
|
findGsapPositionAnimation,
|
|
32
32
|
pickClosestToPlayhead,
|
|
@@ -211,7 +211,7 @@ export async function tryGsapDragIntercept(
|
|
|
211
211
|
const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
|
|
212
212
|
if (!hasNonHold && !hasKeyframedPosTween) {
|
|
213
213
|
const existingSet =
|
|
214
|
-
posAnim && posAnim
|
|
214
|
+
posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector
|
|
215
215
|
? posAnim
|
|
216
216
|
: findExistingPositionWrite(resolvedAnimations, selector);
|
|
217
217
|
await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
|
|
@@ -300,8 +300,8 @@ export async function tryGsapRotationIntercept(
|
|
|
300
300
|
// mirroring the static position set. Idempotent: re-rotate updates an existing
|
|
301
301
|
// rotation set in place, else add a new one. This replaces the old
|
|
302
302
|
// `--hf-studio-rotation` CSS-var fallback (the same dual-channel bug class).
|
|
303
|
-
if (!anim) {
|
|
304
|
-
const existingSet = findRotationSetAnimation(resolvedAnimations, selector);
|
|
303
|
+
if (!anim || isInstantHold(anim)) {
|
|
304
|
+
const existingSet = anim ?? findRotationSetAnimation(resolvedAnimations, selector);
|
|
305
305
|
await commitStaticGsapRotation(selection, newRotation, selector, existingSet, {
|
|
306
306
|
commitMutation,
|
|
307
307
|
fetchAnimations: fetchFallbackAnimations,
|
|
@@ -16,6 +16,7 @@ export interface MutationResult {
|
|
|
16
16
|
export interface CommitMutationOptions {
|
|
17
17
|
label: string;
|
|
18
18
|
coalesceKey?: string;
|
|
19
|
+
coalesceMs?: number;
|
|
19
20
|
softReload?: boolean;
|
|
20
21
|
skipReload?: boolean;
|
|
21
22
|
beforeReload?: () => void;
|
|
@@ -38,11 +39,20 @@ export interface CommitMutationOptions {
|
|
|
38
39
|
instantPatch?: { selector: string; change: RuntimeTweenChange };
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
export
|
|
42
|
-
selection: DomEditSelection
|
|
43
|
-
mutation: Record<string, unknown
|
|
44
|
-
options: CommitMutationOptions
|
|
45
|
-
|
|
42
|
+
export interface CommitMutationCall {
|
|
43
|
+
selection: DomEditSelection;
|
|
44
|
+
mutation: Record<string, unknown>;
|
|
45
|
+
options: CommitMutationOptions;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CommitMutation {
|
|
49
|
+
(
|
|
50
|
+
selection: DomEditSelection,
|
|
51
|
+
mutation: Record<string, unknown>,
|
|
52
|
+
options: CommitMutationOptions,
|
|
53
|
+
): Promise<void>;
|
|
54
|
+
batch?: (calls: CommitMutationCall[], options: CommitMutationOptions) => Promise<void>;
|
|
55
|
+
}
|
|
46
56
|
|
|
47
57
|
export type SafeGsapCommitMutation = (
|
|
48
58
|
selection: DomEditSelection,
|
|
@@ -66,6 +76,7 @@ export interface GsapScriptCommitsParams {
|
|
|
66
76
|
label: string;
|
|
67
77
|
kind: EditHistoryKind;
|
|
68
78
|
coalesceKey?: string;
|
|
79
|
+
coalesceMs?: number;
|
|
69
80
|
files: Record<string, { before: string; after: string }>;
|
|
70
81
|
}) => Promise<void>;
|
|
71
82
|
};
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import { isInstantHold, parsePercentageKeyframes } from "./gsapShared";
|
|
4
|
+
|
|
5
|
+
describe("isInstantHold", () => {
|
|
6
|
+
const animation = (method: GsapAnimation["method"], duration?: number) =>
|
|
7
|
+
({ method, duration }) as unknown as GsapAnimation;
|
|
8
|
+
|
|
9
|
+
it("classifies set and duration-zero to/fromTo writes as instant holds", () => {
|
|
10
|
+
expect(isInstantHold(animation("set"))).toBe(true);
|
|
11
|
+
expect(isInstantHold(animation("to", 0))).toBe(true);
|
|
12
|
+
expect(isInstantHold(animation("fromTo", 0))).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("does not classify live tweens or duration-zero from writes as instant holds", () => {
|
|
16
|
+
expect(isInstantHold(animation("to", 1))).toBe(false);
|
|
17
|
+
expect(isInstantHold(animation("fromTo"))).toBe(false);
|
|
18
|
+
expect(isInstantHold(animation("from", 0))).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
3
21
|
|
|
4
22
|
describe("parsePercentageKeyframes", () => {
|
|
5
23
|
it("parses the object/percentage form", () => {
|
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -33,6 +33,19 @@ export const PROPERTY_DEFAULTS: Record<string, number> = {
|
|
|
33
33
|
height: 100,
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* A timeline write that applies an instantaneous value and then holds it.
|
|
38
|
+
* `set()` is always a hold; authored `to()` / `fromTo()` tweens are holds only
|
|
39
|
+
* when their resolved duration is exactly zero.
|
|
40
|
+
*/
|
|
41
|
+
export function isInstantHold(animation: GsapAnimation): boolean {
|
|
42
|
+
return (
|
|
43
|
+
animation.method === "set" ||
|
|
44
|
+
((animation.method === "to" || animation.method === "fromTo") &&
|
|
45
|
+
resolveTweenDuration(animation) === 0)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
// ── Selector resolution ───────────────────────────────────────────────────────
|
|
37
50
|
|
|
38
51
|
/**
|
|
@@ -61,6 +74,7 @@ export function computeElementPercentage(
|
|
|
61
74
|
if (animation) {
|
|
62
75
|
const start = resolveTweenStart(animation);
|
|
63
76
|
const duration = resolveTweenDuration(animation);
|
|
77
|
+
if (duration <= 0) return 0;
|
|
64
78
|
if (start !== null) {
|
|
65
79
|
return absoluteToPercentage(currentTime, start, duration);
|
|
66
80
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { patchDocumentRootDuration } from "./timelineEditingHelpers";
|
|
4
|
+
|
|
5
|
+
// The regression these guard against: timing edits now soft-reload instead of
|
|
6
|
+
// full-reloading the iframe, so the runtime recomputes the composition length
|
|
7
|
+
// from the ROOT's `data-duration` and posts it back — overwriting the studio's
|
|
8
|
+
// optimistic duration readout. Patching the live root's `data-duration` in the
|
|
9
|
+
// same tick keeps the runtime's post-soft-reload report in agreement, so the
|
|
10
|
+
// readout stays live on grow AND shrink instead of snapping back.
|
|
11
|
+
function parse(html: string): Document {
|
|
12
|
+
return new DOMParser().parseFromString(html, "text/html");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("patchDocumentRootDuration", () => {
|
|
16
|
+
it("writes the content end onto the TOP-LEVEL composition root (grow)", () => {
|
|
17
|
+
const doc = parse(`
|
|
18
|
+
<div data-composition-id="root" data-duration="15.18">
|
|
19
|
+
<div data-composition-id="scene" data-start="0" data-duration="8"></div>
|
|
20
|
+
<div data-start="12" data-duration="8"></div>
|
|
21
|
+
</div>
|
|
22
|
+
`);
|
|
23
|
+
expect(patchDocumentRootDuration(doc, 20)).toBe(true);
|
|
24
|
+
const root = doc.querySelector('[data-composition-id="root"]');
|
|
25
|
+
expect(root?.getAttribute("data-duration")).toBe("20");
|
|
26
|
+
// Nested sub-composition is untouched — only the outermost root is the length.
|
|
27
|
+
expect(doc.querySelector('[data-composition-id="scene"]')?.getAttribute("data-duration")).toBe(
|
|
28
|
+
"8",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("shrinks the root duration too (not grow-only)", () => {
|
|
33
|
+
const doc = parse(`<div data-composition-id="root" data-duration="15.18"></div>`);
|
|
34
|
+
expect(patchDocumentRootDuration(doc, 6.5)).toBe(true);
|
|
35
|
+
expect(doc.querySelector("[data-composition-id]")?.getAttribute("data-duration")).toBe("6.5");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("picks the composition with no ancestor composition as the root", () => {
|
|
39
|
+
// Root appears AFTER a nested one in document order — selection must be by
|
|
40
|
+
// ancestry, not first-match, matching the runtime's own root resolver.
|
|
41
|
+
const doc = parse(`
|
|
42
|
+
<section data-composition-id="root" data-duration="10">
|
|
43
|
+
<div data-composition-id="inner" data-duration="4"></div>
|
|
44
|
+
</section>
|
|
45
|
+
`);
|
|
46
|
+
patchDocumentRootDuration(doc, 12);
|
|
47
|
+
expect(doc.querySelector('[data-composition-id="root"]')?.getAttribute("data-duration")).toBe(
|
|
48
|
+
"12",
|
|
49
|
+
);
|
|
50
|
+
expect(doc.querySelector('[data-composition-id="inner"]')?.getAttribute("data-duration")).toBe(
|
|
51
|
+
"4",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("no-ops on a non-positive or non-finite content end (never collapses to 0)", () => {
|
|
56
|
+
const doc = parse(`<div data-composition-id="root" data-duration="15.18"></div>`);
|
|
57
|
+
expect(patchDocumentRootDuration(doc, 0)).toBe(false);
|
|
58
|
+
expect(patchDocumentRootDuration(doc, -3)).toBe(false);
|
|
59
|
+
expect(patchDocumentRootDuration(doc, Number.NaN)).toBe(false);
|
|
60
|
+
expect(doc.querySelector("[data-composition-id]")?.getAttribute("data-duration")).toBe("15.18");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("no-ops when there is no composition root and reports false", () => {
|
|
64
|
+
const doc = parse(`<div data-start="0" data-duration="5"></div>`);
|
|
65
|
+
expect(patchDocumentRootDuration(doc, 10)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns false for a null document", () => {
|
|
69
|
+
expect(patchDocumentRootDuration(null, 10)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
|
2
|
+
|
|
3
|
+
export function patchDocumentRootDuration(
|
|
4
|
+
doc: Document | null | undefined,
|
|
5
|
+
contentEnd: number,
|
|
6
|
+
): boolean {
|
|
7
|
+
if (!doc || !Number.isFinite(contentEnd) || contentEnd <= 0) return false;
|
|
8
|
+
const nodes = Array.from(doc.querySelectorAll("[data-composition-id]"));
|
|
9
|
+
const root =
|
|
10
|
+
nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? nodes[0] ?? null;
|
|
11
|
+
if (!root) return false;
|
|
12
|
+
root.setAttribute("data-duration", formatTimelineAttributeNumber(contentEnd));
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
applyTimelineStackingReorder,
|
|
5
|
+
buildTimelineMoveTimingPatch,
|
|
6
|
+
deleteSelectedKeyframes,
|
|
7
|
+
extendRootDurationIfNeeded,
|
|
8
|
+
persistTimelineBatchEdit,
|
|
9
|
+
type PersistTimelineBatchChange,
|
|
10
|
+
} from "./timelineEditingHelpers";
|
|
4
11
|
import type { TimelineElement } from "../player/store/playerStore";
|
|
5
12
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
13
|
+
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
6
14
|
|
|
7
15
|
afterEach(() => {
|
|
8
16
|
usePlayerStore.getState().reset();
|
|
@@ -102,3 +110,119 @@ describe("extendRootDurationIfNeeded", () => {
|
|
|
102
110
|
expect(usePlayerStore.getState().duration).toBe(5);
|
|
103
111
|
});
|
|
104
112
|
});
|
|
113
|
+
|
|
114
|
+
describe("persistTimelineBatchEdit", () => {
|
|
115
|
+
const SOURCE = `<div id="root"><video id="a" class="clip" data-start="1" data-track-index="0"></video><video id="b" class="clip" data-start="2" data-track-index="1"></video></div>`;
|
|
116
|
+
|
|
117
|
+
function batchInput(changes: PersistTimelineBatchChange[], writes: Array<[string, string]>) {
|
|
118
|
+
return {
|
|
119
|
+
projectId: "p1",
|
|
120
|
+
activeCompPath: "index.html",
|
|
121
|
+
label: "Move timeline clips",
|
|
122
|
+
changes,
|
|
123
|
+
writeProjectFile: async (path: string, content: string) => {
|
|
124
|
+
writes.push([path, content]);
|
|
125
|
+
},
|
|
126
|
+
recordEdit: async () => {},
|
|
127
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
128
|
+
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function stubReadFileContent(content: string) {
|
|
133
|
+
vi.stubGlobal(
|
|
134
|
+
"fetch",
|
|
135
|
+
vi.fn(async () => ({
|
|
136
|
+
ok: true,
|
|
137
|
+
json: async () => ({ content }),
|
|
138
|
+
})),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function moveMember(
|
|
143
|
+
id: string,
|
|
144
|
+
start: number,
|
|
145
|
+
fromTrack: number,
|
|
146
|
+
toTrack: number,
|
|
147
|
+
): PersistTimelineBatchChange {
|
|
148
|
+
return {
|
|
149
|
+
element: el({ id, tag: "video", domId: id, start, track: fromTrack }),
|
|
150
|
+
buildPatches: (original, target) =>
|
|
151
|
+
buildTimelineMoveTimingPatch(original, target, start, 5, toTrack),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function runBatch(changes: PersistTimelineBatchChange[]) {
|
|
156
|
+
stubReadFileContent(SOURCE);
|
|
157
|
+
const writes: Array<[string, string]> = [];
|
|
158
|
+
await persistTimelineBatchEdit(batchInput(changes, writes));
|
|
159
|
+
return writes;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
afterEach(() => {
|
|
163
|
+
vi.unstubAllGlobals();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("skips no-op members instead of aborting the batch (track-insert renumber)", async () => {
|
|
167
|
+
// A track-insert renumber can include a member whose attributes already
|
|
168
|
+
// hold the target values — its patch is string-identical. The batch must
|
|
169
|
+
// skip it and still persist the members that DID change.
|
|
170
|
+
const writes = await runBatch([
|
|
171
|
+
// no-op: data-start already "1", track already 0
|
|
172
|
+
moveMember("a", 1, 0, 0),
|
|
173
|
+
// real change: track 1 -> 2
|
|
174
|
+
moveMember("b", 2, 1, 2),
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
expect(writes).toHaveLength(1);
|
|
178
|
+
expect(writes[0]![0]).toBe("index.html");
|
|
179
|
+
expect(writes[0]![1]).toContain('id="b" class="clip" data-start="2" data-track-index="2"');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("saves nothing when every member is a no-op", async () => {
|
|
183
|
+
const writes = await runBatch([moveMember("a", 1, 0, 0)]);
|
|
184
|
+
|
|
185
|
+
expect(writes).toHaveLength(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("throws on a mistargeted member instead of silently dropping it", async () => {
|
|
189
|
+
// A member whose target does not resolve in the source (stale id) patches
|
|
190
|
+
// to the identical string too — but that is a targeting FAILURE, not an
|
|
191
|
+
// already-at-target no-op, and must abort the batch like the single path.
|
|
192
|
+
stubReadFileContent(SOURCE);
|
|
193
|
+
const writes: Array<[string, string]> = [];
|
|
194
|
+
|
|
195
|
+
await expect(
|
|
196
|
+
persistTimelineBatchEdit(batchInput([moveMember("ghost", 3, 0, 2)], writes)),
|
|
197
|
+
).rejects.toThrow("Unable to patch timeline element ghost in index.html");
|
|
198
|
+
expect(writes).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("deleteSelectedKeyframes", () => {
|
|
203
|
+
it("coalesces all removals and reloads only after the last one", () => {
|
|
204
|
+
usePlayerStore.setState({
|
|
205
|
+
selectedElementId: "card",
|
|
206
|
+
selectedKeyframes: new Set(["card:10", "card:50", "card:90"]),
|
|
207
|
+
});
|
|
208
|
+
const handleGsapRemoveKeyframe =
|
|
209
|
+
vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
|
|
210
|
+
|
|
211
|
+
deleteSelectedKeyframes({
|
|
212
|
+
selectedGsapAnimations: [{ id: "card-position", keyframes: {} }],
|
|
213
|
+
handleGsapRemoveKeyframe,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(3);
|
|
217
|
+
const options = handleGsapRemoveKeyframe.mock.calls.map((call) => call[2]);
|
|
218
|
+
expect(new Set(options.map((entry) => entry?.coalesceKey)).size).toBe(1);
|
|
219
|
+
expect(options).toEqual([
|
|
220
|
+
expect.objectContaining({ coalesceMs: Infinity, skipReload: true }),
|
|
221
|
+
expect.objectContaining({ coalesceMs: Infinity, skipReload: true }),
|
|
222
|
+
expect.objectContaining({ coalesceMs: Infinity, softReload: true }),
|
|
223
|
+
]);
|
|
224
|
+
expect(options[0]).not.toHaveProperty("softReload");
|
|
225
|
+
expect(options[1]).not.toHaveProperty("softReload");
|
|
226
|
+
expect(options[2]).not.toHaveProperty("skipReload");
|
|
227
|
+
});
|
|
228
|
+
});
|