@hyperframes/studio 0.7.90 → 0.7.93
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-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +40 -36
- package/dist/index.html +2 -2
- package/dist/index.js +9598 -6798
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +10 -4
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +26 -8
- package/src/player/components/Timeline.tsx +87 -88
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +20 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
- package/src/player/components/TimelineClipDiamonds.tsx +118 -223
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +64 -7
- package/src/player/components/TimelineLanes.tsx +84 -66
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +18 -21
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +26 -29
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +106 -23
- package/src/player/store/playerStore.ts +43 -40
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-Cql15Yur.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -70,6 +70,8 @@ function keyframedScaleFixture(): GsapAnimation {
|
|
|
70
70
|
} as unknown as GsapAnimation;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Resize/rotation hold tests intentionally pin the same no-conversion contract.
|
|
74
|
+
// fallow-ignore-next-line code-duplication
|
|
73
75
|
it("updates a duration-zero size hold in place instead of converting it to keyframes", async () => {
|
|
74
76
|
const el = document.createElement("div");
|
|
75
77
|
el.id = "box";
|
|
@@ -96,7 +98,7 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
|
|
|
96
98
|
commitMutation,
|
|
97
99
|
);
|
|
98
100
|
|
|
99
|
-
expect(handled).
|
|
101
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
100
102
|
expect(commitMutation).toHaveBeenCalledTimes(1);
|
|
101
103
|
expect(commitMutation.mock.calls[0]![1]).toEqual({
|
|
102
104
|
type: "update-properties",
|
|
@@ -115,6 +117,92 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
|
|
|
115
117
|
);
|
|
116
118
|
});
|
|
117
119
|
|
|
120
|
+
// fallow-ignore-next-line code-duplication
|
|
121
|
+
it("requires explicit unroll for helper-authored resize before mutating", async () => {
|
|
122
|
+
const el = document.createElement("div");
|
|
123
|
+
el.id = "box";
|
|
124
|
+
document.body.append(el);
|
|
125
|
+
const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
|
|
126
|
+
const helperSize = {
|
|
127
|
+
id: "#box-to-size",
|
|
128
|
+
targetSelector: "#box",
|
|
129
|
+
propertyGroup: "size",
|
|
130
|
+
method: "to",
|
|
131
|
+
properties: { width: 150, height: 150 },
|
|
132
|
+
duration: 1,
|
|
133
|
+
provenance: { kind: "helper", fn: "grow", callSite: 1 },
|
|
134
|
+
} as unknown as GsapAnimation;
|
|
135
|
+
const commitMutation = vi.fn();
|
|
136
|
+
|
|
137
|
+
await expect(
|
|
138
|
+
tryGsapResizeIntercept(
|
|
139
|
+
selection,
|
|
140
|
+
{ width: 344, height: 344 },
|
|
141
|
+
[helperSize],
|
|
142
|
+
null,
|
|
143
|
+
commitMutation,
|
|
144
|
+
),
|
|
145
|
+
).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
|
|
146
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// fallow-ignore-next-line code-duplication
|
|
150
|
+
it("reuses the ownership parse instead of fetching a resolved size group twice", async () => {
|
|
151
|
+
const el = document.createElement("div");
|
|
152
|
+
el.id = "box";
|
|
153
|
+
document.body.append(el);
|
|
154
|
+
const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
|
|
155
|
+
const sizeHold = {
|
|
156
|
+
id: "#box-set-size",
|
|
157
|
+
targetSelector: "#box",
|
|
158
|
+
propertyGroup: "size",
|
|
159
|
+
method: "set",
|
|
160
|
+
properties: { width: 150, height: 150 },
|
|
161
|
+
} as unknown as GsapAnimation;
|
|
162
|
+
const fetchAnimations = vi.fn().mockResolvedValue([sizeHold]);
|
|
163
|
+
const commitMutation = vi.fn();
|
|
164
|
+
|
|
165
|
+
await expect(
|
|
166
|
+
tryGsapResizeIntercept(
|
|
167
|
+
selection,
|
|
168
|
+
{ width: 344, height: 344 },
|
|
169
|
+
[],
|
|
170
|
+
null,
|
|
171
|
+
commitMutation,
|
|
172
|
+
fetchAnimations,
|
|
173
|
+
),
|
|
174
|
+
).resolves.toEqual({ status: "persisted" });
|
|
175
|
+
expect(fetchAnimations).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(commitMutation).toHaveBeenCalledWith(
|
|
177
|
+
selection,
|
|
178
|
+
expect.objectContaining({ type: "update-properties", animationId: sizeHold.id }),
|
|
179
|
+
expect.anything(),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("blocks when runtime size motion exists but the authored tween cannot be resolved", async () => {
|
|
184
|
+
const el = document.createElement("div");
|
|
185
|
+
el.id = "box";
|
|
186
|
+
document.body.append(el);
|
|
187
|
+
const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
|
|
188
|
+
const liveSizeTween = {
|
|
189
|
+
targets: () => [el],
|
|
190
|
+
vars: { width: 300, duration: 1 },
|
|
191
|
+
duration: () => 1,
|
|
192
|
+
startTime: () => 0,
|
|
193
|
+
};
|
|
194
|
+
const iframe = {
|
|
195
|
+
contentWindow: { __timelines: { main: { getChildren: () => [liveSizeTween] } } },
|
|
196
|
+
contentDocument: document,
|
|
197
|
+
} as unknown as HTMLIFrameElement;
|
|
198
|
+
const commitMutation = vi.fn();
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
tryGsapResizeIntercept(selection, { width: 344, height: 344 }, [], iframe, commitMutation),
|
|
202
|
+
).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
|
|
203
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
118
206
|
it("computes a finite zero percentage for a zero-duration tween", () => {
|
|
119
207
|
const animation = {
|
|
120
208
|
id: "#box-to-0-size",
|
|
@@ -158,7 +246,7 @@ async function runResize(
|
|
|
158
246
|
commitMutation as never,
|
|
159
247
|
async () => [keyframedScaleFixture()],
|
|
160
248
|
);
|
|
161
|
-
expect(handled).
|
|
249
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
162
250
|
return committed;
|
|
163
251
|
}
|
|
164
252
|
|
|
@@ -24,11 +24,16 @@ 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 { isInstantHold, selectorFromSelection } from "./gsapShared";
|
|
27
|
+
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
28
28
|
import { roundTo3 } from "../utils/rounding";
|
|
29
29
|
import { resolveGroupTween, POSITION_CHANNELS } from "./gsapRuntimeBridge";
|
|
30
30
|
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
31
31
|
import { logResize } from "../utils/resizeDebug";
|
|
32
|
+
import {
|
|
33
|
+
animationWritesAnyProperty,
|
|
34
|
+
directEditOutcomeForProperties,
|
|
35
|
+
type GsapEditOutcome,
|
|
36
|
+
} from "./gsapEditOutcome";
|
|
32
37
|
|
|
33
38
|
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
|
|
34
39
|
|
|
@@ -54,21 +59,44 @@ export async function tryGsapResizeIntercept(
|
|
|
54
59
|
iframe: HTMLIFrameElement | null,
|
|
55
60
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
56
61
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
57
|
-
): Promise<
|
|
62
|
+
): Promise<GsapEditOutcome> {
|
|
63
|
+
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
64
|
+
const allKnownAnimations = [...animations, ...fetchedAnimations];
|
|
58
65
|
// If the element already has a scale-group tween, resize should modify scale
|
|
59
66
|
// (the user is resizing something whose visual size is driven by scale).
|
|
60
67
|
// Otherwise, use the size group (width/height).
|
|
61
|
-
const hasScaleGroup =
|
|
68
|
+
const hasScaleGroup = allKnownAnimations.some((a) => a.propertyGroup === "scale");
|
|
62
69
|
const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
|
|
70
|
+
const resizeProperties =
|
|
71
|
+
resizeGroup === "scale" ? new Set(["scale", "scaleX", "scaleY"]) : new Set(["width", "height"]);
|
|
72
|
+
const editability = directEditOutcomeForProperties(allKnownAnimations, resizeProperties);
|
|
73
|
+
if (editability.status === "blocked") return editability;
|
|
74
|
+
const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
|
|
75
|
+
// The initial ownership fetch already supplied the complete parse. Only retain
|
|
76
|
+
// the fetch callback when a legacy mixed tween may be split and must then be
|
|
77
|
+
// re-read; otherwise resolveGroupTween would perform the same network read twice.
|
|
78
|
+
const postSplitFetch = workingAnimations.some((animation) => !animation.propertyGroup)
|
|
79
|
+
? fetchFallbackAnimations
|
|
80
|
+
: undefined;
|
|
63
81
|
const resolved = await resolveGroupTween(
|
|
64
82
|
resizeGroup,
|
|
65
|
-
|
|
83
|
+
workingAnimations,
|
|
66
84
|
selection,
|
|
67
85
|
commitMutation,
|
|
68
|
-
|
|
86
|
+
postSplitFetch,
|
|
69
87
|
);
|
|
70
88
|
|
|
71
|
-
let anim =
|
|
89
|
+
let anim =
|
|
90
|
+
resolved?.anim && animationWritesAnyProperty(resolved.anim, resizeProperties)
|
|
91
|
+
? resolved.anim
|
|
92
|
+
: null;
|
|
93
|
+
const liveSelector = selectorFromSelection(selection);
|
|
94
|
+
const hasLiveResizeTween = liveSelector
|
|
95
|
+
? hasNonHoldTweenForElement(iframe, liveSelector, undefined, [...resizeProperties])
|
|
96
|
+
: false;
|
|
97
|
+
if (!anim && hasLiveResizeTween) {
|
|
98
|
+
return { status: "blocked", reason: "source-uneditable" };
|
|
99
|
+
}
|
|
72
100
|
logResize("intercept-enter", {
|
|
73
101
|
hasScaleGroup,
|
|
74
102
|
resizeGroup,
|
|
@@ -77,16 +105,16 @@ export async function tryGsapResizeIntercept(
|
|
|
77
105
|
size,
|
|
78
106
|
});
|
|
79
107
|
if (!anim || isInstantHold(anim)) {
|
|
80
|
-
const sel = selectorFromSelection(selection);
|
|
81
|
-
if (!sel) return
|
|
82
|
-
const sizeSet = anim ?? findSizeSetAnimation(
|
|
108
|
+
const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
|
|
109
|
+
if (!sel) return { status: "blocked", reason: "no-selector" };
|
|
110
|
+
const sizeSet = anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element);
|
|
83
111
|
|
|
84
112
|
// If the element is animated (has a real tween, not just a static size
|
|
85
113
|
// hold), keyframe the size at the playhead so other keyframes keep theirs —
|
|
86
114
|
// instead of a global set that resizes every frame.
|
|
87
115
|
if (resizeGroup === "size") {
|
|
88
116
|
const animatedTween = pickClosestToPlayhead(
|
|
89
|
-
|
|
117
|
+
workingAnimations.filter((a) => !isInstantHold(a) && resolveTweenDuration(a) > 0),
|
|
90
118
|
);
|
|
91
119
|
if (animatedTween) {
|
|
92
120
|
logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
|
|
@@ -98,7 +126,7 @@ export async function tryGsapResizeIntercept(
|
|
|
98
126
|
animatedTween,
|
|
99
127
|
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
100
128
|
);
|
|
101
|
-
if (handled) return
|
|
129
|
+
if (handled) return { status: "persisted" };
|
|
102
130
|
}
|
|
103
131
|
}
|
|
104
132
|
|
|
@@ -107,11 +135,11 @@ export async function tryGsapResizeIntercept(
|
|
|
107
135
|
commitMutation,
|
|
108
136
|
fetchAnimations: fetchFallbackAnimations,
|
|
109
137
|
});
|
|
110
|
-
return
|
|
138
|
+
return { status: "persisted" };
|
|
111
139
|
}
|
|
112
140
|
|
|
113
141
|
const tweenDuration = resolveTweenDuration(anim);
|
|
114
|
-
if (tweenDuration <= 0) return
|
|
142
|
+
if (tweenDuration <= 0) return { status: "blocked", reason: "source-uneditable" };
|
|
115
143
|
|
|
116
144
|
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
117
145
|
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
@@ -273,7 +301,7 @@ export async function tryGsapResizeIntercept(
|
|
|
273
301
|
"Resize animation",
|
|
274
302
|
);
|
|
275
303
|
await finalizeScaleResizeCommit();
|
|
276
|
-
return
|
|
304
|
+
return { status: "persisted" };
|
|
277
305
|
}
|
|
278
306
|
|
|
279
307
|
const ct = usePlayerStore.getState().currentTime;
|
|
@@ -385,7 +413,7 @@ export async function tryGsapResizeIntercept(
|
|
|
385
413
|
},
|
|
386
414
|
);
|
|
387
415
|
await finalizeScaleResizeCommit();
|
|
388
|
-
return
|
|
416
|
+
return { status: "persisted" };
|
|
389
417
|
}
|
|
390
418
|
|
|
391
419
|
const SIZE_PROPS = new Set(["width", "height"]);
|
|
@@ -407,7 +435,7 @@ export async function tryGsapResizeIntercept(
|
|
|
407
435
|
{ label: `Resize (keyframe ${pct}%)`, softReload: true },
|
|
408
436
|
);
|
|
409
437
|
await finalizeScaleResizeCommit();
|
|
410
|
-
return
|
|
438
|
+
return { status: "persisted" };
|
|
411
439
|
}
|
|
412
440
|
|
|
413
441
|
// ── Rotation intercept ────────────────────────────────────────────────────
|
|
@@ -59,6 +59,67 @@ const stalePositionAnim = {
|
|
|
59
59
|
afterEach(() => vi.restoreAllMocks());
|
|
60
60
|
|
|
61
61
|
describe("tryGsapDragIntercept — stale-parse guard (no resurrection after delete-all)", () => {
|
|
62
|
+
async function runHelperOwnedDrag(fetchFallbackAnimations?: () => Promise<GsapAnimation[]>) {
|
|
63
|
+
const helperTween = {
|
|
64
|
+
...stalePositionAnim,
|
|
65
|
+
provenance: { kind: "helper", fn: "slam", callSite: 1 },
|
|
66
|
+
} as GsapAnimation;
|
|
67
|
+
const commitMutation = vi.fn();
|
|
68
|
+
const result = await tryGsapDragIntercept(
|
|
69
|
+
selection,
|
|
70
|
+
{ x: 10, y: 10 },
|
|
71
|
+
[helperTween],
|
|
72
|
+
fakeIframe("puck-b", []),
|
|
73
|
+
commitMutation,
|
|
74
|
+
fetchFallbackAnimations,
|
|
75
|
+
);
|
|
76
|
+
return { result, commitMutation };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
it("blocks a live runtime position tween when no editable source mapping exists", async () => {
|
|
80
|
+
const liveTween = {
|
|
81
|
+
targets: () => [{ id: "puck-b" }],
|
|
82
|
+
vars: { y: 18, duration: 1 },
|
|
83
|
+
duration: () => 1,
|
|
84
|
+
startTime: () => 0,
|
|
85
|
+
};
|
|
86
|
+
const commitMutation = vi.fn();
|
|
87
|
+
|
|
88
|
+
const result = await tryGsapDragIntercept(
|
|
89
|
+
selection,
|
|
90
|
+
{ x: 25, y: -10 },
|
|
91
|
+
[],
|
|
92
|
+
fakeIframe("puck-b", [liveTween]),
|
|
93
|
+
commitMutation,
|
|
94
|
+
vi.fn().mockResolvedValue([]),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(result).toEqual({ status: "blocked", reason: "source-uneditable" });
|
|
98
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("blocks a selector that cannot safely address one writable element", async () => {
|
|
102
|
+
const selectorless = {
|
|
103
|
+
...selection,
|
|
104
|
+
id: undefined,
|
|
105
|
+
selector: undefined,
|
|
106
|
+
} as unknown as DomEditSelection;
|
|
107
|
+
const result = await tryGsapDragIntercept(selectorless, { x: 1, y: 1 }, [], null, vi.fn());
|
|
108
|
+
expect(result).toEqual({ status: "blocked", reason: "no-selector" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("requires explicit unroll for helper-authored source and performs no mutation", async () => {
|
|
112
|
+
const { result, commitMutation } = await runHelperOwnedDrag();
|
|
113
|
+
expect(result).toEqual({ status: "blocked", reason: "unroll-required" });
|
|
114
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("does not let an empty fallback response bypass cached helper provenance", async () => {
|
|
118
|
+
const { result, commitMutation } = await runHelperOwnedDrag(vi.fn().mockResolvedValue([]));
|
|
119
|
+
expect(result).toEqual({ status: "blocked", reason: "unroll-required" });
|
|
120
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
62
123
|
it("commits a static set (not the stale tween) when the runtime has no live position motion", async () => {
|
|
63
124
|
const commitMutation = vi.fn();
|
|
64
125
|
// Runtime empty (tween deleted) — readRuntimeKeyframes returns null, so the
|
|
@@ -73,7 +134,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
|
|
73
134
|
commitMutation,
|
|
74
135
|
);
|
|
75
136
|
|
|
76
|
-
expect(handled).
|
|
137
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
77
138
|
// No existing `set` for the selector → one `add` mutation with `method:"set"`.
|
|
78
139
|
expect(commitMutation).toHaveBeenCalledTimes(1);
|
|
79
140
|
const [, mutation] = commitMutation.mock.calls[0];
|
|
@@ -112,7 +173,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
|
|
112
173
|
commitMutation,
|
|
113
174
|
);
|
|
114
175
|
|
|
115
|
-
expect(handled).
|
|
176
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
116
177
|
const updates = commitMutation.mock.calls.filter(([, m]) => m.type === "update-properties");
|
|
117
178
|
expect(updates).toHaveLength(1);
|
|
118
179
|
expect(updates[0][1]).toEqual({
|
|
@@ -151,7 +212,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
|
|
151
212
|
commitMutation,
|
|
152
213
|
);
|
|
153
214
|
|
|
154
|
-
expect(handled).
|
|
215
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
155
216
|
// One atomic in-place update, NOT an `add`/`add-keyframe`.
|
|
156
217
|
const types = commitMutation.mock.calls.map(([, m]) => m.type);
|
|
157
218
|
expect(types).toEqual(["update-properties"]);
|
|
@@ -179,6 +240,124 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
|
|
179
240
|
});
|
|
180
241
|
|
|
181
242
|
describe("tryGsapRotationIntercept — instant holds", () => {
|
|
243
|
+
it("requires explicit unroll for helper-authored rotation before mutating", async () => {
|
|
244
|
+
const helperRotation = {
|
|
245
|
+
id: "#puck-b-to-rotation",
|
|
246
|
+
targetSelector: "#puck-b",
|
|
247
|
+
propertyGroup: "rotation",
|
|
248
|
+
method: "to",
|
|
249
|
+
properties: { rotation: 45 },
|
|
250
|
+
position: 0,
|
|
251
|
+
duration: 1,
|
|
252
|
+
provenance: { kind: "helper", fn: "spin", callSite: 1 },
|
|
253
|
+
} as unknown as GsapAnimation;
|
|
254
|
+
const commitMutation = vi.fn();
|
|
255
|
+
|
|
256
|
+
await expect(
|
|
257
|
+
tryGsapRotationIntercept(selection, 75, [helperRotation], null, commitMutation),
|
|
258
|
+
).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
|
|
259
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Mirrors resize by design: both geometry routes must reuse one ownership parse.
|
|
263
|
+
// fallow-ignore-next-line code-duplication
|
|
264
|
+
it("reuses the ownership parse instead of fetching a resolved rotation group twice", async () => {
|
|
265
|
+
const rotationHold = {
|
|
266
|
+
id: "#puck-b-set-rotation",
|
|
267
|
+
targetSelector: "#puck-b",
|
|
268
|
+
propertyGroup: "rotation",
|
|
269
|
+
method: "set",
|
|
270
|
+
properties: { rotation: 45 },
|
|
271
|
+
} as unknown as GsapAnimation;
|
|
272
|
+
const fetchAnimations = vi.fn().mockResolvedValue([rotationHold]);
|
|
273
|
+
const commitMutation = vi.fn();
|
|
274
|
+
|
|
275
|
+
await expect(
|
|
276
|
+
tryGsapRotationIntercept(selection, 75, [], null, commitMutation, fetchAnimations),
|
|
277
|
+
).resolves.toEqual({ status: "persisted" });
|
|
278
|
+
expect(fetchAnimations).toHaveBeenCalledTimes(1);
|
|
279
|
+
expect(commitMutation).toHaveBeenCalledWith(
|
|
280
|
+
selection,
|
|
281
|
+
expect.objectContaining({ type: "update-property", animationId: rotationHold.id }),
|
|
282
|
+
expect.anything(),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("rejects a selectorless rotation instead of reporting a handled no-op", async () => {
|
|
287
|
+
const selectorless = {
|
|
288
|
+
...selection,
|
|
289
|
+
id: undefined,
|
|
290
|
+
selector: undefined,
|
|
291
|
+
} as unknown as DomEditSelection;
|
|
292
|
+
const commitMutation = vi.fn();
|
|
293
|
+
|
|
294
|
+
await expect(
|
|
295
|
+
tryGsapRotationIntercept(selectorless, 75, [], null, commitMutation),
|
|
296
|
+
).resolves.toEqual({ status: "blocked", reason: "no-selector" });
|
|
297
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("blocks helper-authored 3D rotation channels before mutating the group", async () => {
|
|
301
|
+
const helperRotationX = {
|
|
302
|
+
id: "#puck-b-to-rotation",
|
|
303
|
+
targetSelector: "#puck-b",
|
|
304
|
+
propertyGroup: "rotation",
|
|
305
|
+
method: "to",
|
|
306
|
+
properties: { rotationX: 45 },
|
|
307
|
+
duration: 1,
|
|
308
|
+
provenance: { kind: "helper", fn: "tilt", callSite: 1 },
|
|
309
|
+
} as unknown as GsapAnimation;
|
|
310
|
+
const commitMutation = vi.fn();
|
|
311
|
+
|
|
312
|
+
await expect(
|
|
313
|
+
tryGsapRotationIntercept(selection, 75, [helperRotationX], null, commitMutation),
|
|
314
|
+
).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
|
|
315
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("does not let an unrelated helper-authored skew tween block 2D rotation", async () => {
|
|
319
|
+
const helperSkew = {
|
|
320
|
+
id: "#puck-b-to-rotation",
|
|
321
|
+
targetSelector: "#puck-b",
|
|
322
|
+
propertyGroup: "rotation",
|
|
323
|
+
method: "to",
|
|
324
|
+
properties: { skewX: 12 },
|
|
325
|
+
duration: 1,
|
|
326
|
+
provenance: { kind: "helper", fn: "skew", callSite: 1 },
|
|
327
|
+
} as unknown as GsapAnimation;
|
|
328
|
+
const commitMutation = vi.fn();
|
|
329
|
+
|
|
330
|
+
await expect(
|
|
331
|
+
tryGsapRotationIntercept(selection, 75, [helperSkew], null, commitMutation),
|
|
332
|
+
).resolves.toEqual({ status: "persisted" });
|
|
333
|
+
expect(commitMutation).toHaveBeenCalledWith(
|
|
334
|
+
selection,
|
|
335
|
+
expect.objectContaining({ type: "add", properties: { rotation: 75 } }),
|
|
336
|
+
expect.anything(),
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("blocks when runtime rotation exists but the authored tween cannot be resolved", async () => {
|
|
341
|
+
const liveRotation = {
|
|
342
|
+
targets: () => [{ id: "puck-b" }],
|
|
343
|
+
vars: { rotation: 45, duration: 1 },
|
|
344
|
+
duration: () => 1,
|
|
345
|
+
startTime: () => 0,
|
|
346
|
+
};
|
|
347
|
+
const commitMutation = vi.fn();
|
|
348
|
+
|
|
349
|
+
await expect(
|
|
350
|
+
tryGsapRotationIntercept(
|
|
351
|
+
selection,
|
|
352
|
+
75,
|
|
353
|
+
[],
|
|
354
|
+
fakeIframe("puck-b", [liveRotation]),
|
|
355
|
+
commitMutation,
|
|
356
|
+
),
|
|
357
|
+
).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
|
|
358
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
359
|
+
});
|
|
360
|
+
|
|
182
361
|
it("updates a duration-zero fromTo hold instead of converting it to keyframes", async () => {
|
|
183
362
|
const rotationHold = {
|
|
184
363
|
id: "#puck-b-fromTo-0-rotation",
|
|
@@ -201,7 +380,7 @@ describe("tryGsapRotationIntercept — instant holds", () => {
|
|
|
201
380
|
commitMutation,
|
|
202
381
|
);
|
|
203
382
|
|
|
204
|
-
expect(handled).
|
|
383
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
205
384
|
expect(commitMutation).toHaveBeenCalledTimes(1);
|
|
206
385
|
expect(commitMutation.mock.calls[0]![1]).toEqual({
|
|
207
386
|
type: "update-property",
|
|
@@ -243,40 +422,29 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
|
|
|
243
422
|
},
|
|
244
423
|
} as unknown as GsapAnimation;
|
|
245
424
|
|
|
246
|
-
|
|
247
|
-
usePlayerStore.setState({ autoKeyframeEnabled:
|
|
425
|
+
async function runAutoKeyframeDrag(enabled: boolean) {
|
|
426
|
+
usePlayerStore.setState({ autoKeyframeEnabled: enabled, currentTime: 2 });
|
|
248
427
|
const commitMutation = vi.fn();
|
|
249
|
-
const iframe = fakeIframe("puck-b", []);
|
|
250
|
-
|
|
251
428
|
const handled = await tryGsapDragIntercept(
|
|
252
429
|
selection,
|
|
253
430
|
{ x: -50, y: 0 },
|
|
254
431
|
[keyframedPositionAnim],
|
|
255
|
-
|
|
432
|
+
fakeIframe("puck-b", []),
|
|
256
433
|
commitMutation,
|
|
257
434
|
);
|
|
435
|
+
return { handled, types: commitMutation.mock.calls.map(([, mutation]) => mutation.type) };
|
|
436
|
+
}
|
|
258
437
|
|
|
259
|
-
|
|
260
|
-
const types =
|
|
438
|
+
it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
|
|
439
|
+
const { handled, types } = await runAutoKeyframeDrag(false);
|
|
440
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
261
441
|
expect(types).toContain("replace-with-keyframes");
|
|
262
442
|
expect(types).not.toContain("add-keyframe");
|
|
263
443
|
});
|
|
264
444
|
|
|
265
445
|
it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const iframe = fakeIframe("puck-b", []);
|
|
269
|
-
|
|
270
|
-
const handled = await tryGsapDragIntercept(
|
|
271
|
-
selection,
|
|
272
|
-
{ x: -50, y: 0 },
|
|
273
|
-
[keyframedPositionAnim],
|
|
274
|
-
iframe,
|
|
275
|
-
commitMutation,
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
expect(handled).toBe(true);
|
|
279
|
-
const types = commitMutation.mock.calls.map(([, m]) => m.type);
|
|
446
|
+
const { handled, types } = await runAutoKeyframeDrag(true);
|
|
447
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
280
448
|
expect(types).not.toContain("replace-with-keyframes");
|
|
281
449
|
});
|
|
282
450
|
});
|
|
@@ -336,7 +504,7 @@ describe("tryGsapDragIntercept — motion paths", () => {
|
|
|
336
504
|
it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => {
|
|
337
505
|
const { commitMutation, handled } = await dragMotionPath(null);
|
|
338
506
|
|
|
339
|
-
expect(handled).
|
|
507
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
340
508
|
expect(commitMutation).toHaveBeenCalledWith(
|
|
341
509
|
selection,
|
|
342
510
|
{
|
|
@@ -363,7 +531,7 @@ describe("tryGsapDragIntercept — motion paths", () => {
|
|
|
363
531
|
it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
|
|
364
532
|
const { commitMutation, handled } = await dragMotionPath(50);
|
|
365
533
|
|
|
366
|
-
expect(handled).
|
|
534
|
+
expect(handled).toEqual({ status: "persisted" });
|
|
367
535
|
expect(commitMutation).toHaveBeenCalledWith(
|
|
368
536
|
selection,
|
|
369
537
|
{
|