@hyperframes/studio 0.7.92 → 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-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CDTN2ZVa.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-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +20 -20
- package/dist/index.html +2 -2
- package/dist/index.js +7055 -5321
- 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/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- 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 +9 -3
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- 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/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/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/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +24 -5
- package/src/player/components/Timeline.tsx +40 -40
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +15 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineLanes.test.tsx +64 -6
- package/src/player/components/TimelineLanes.tsx +81 -64
- 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/timelineDiamondTypes.ts +14 -7
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
- 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/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/useTimelineRowVirtualization.ts +16 -26
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +29 -22
- package/src/player/store/playerStore.ts +24 -29
- package/src/player/store/timelineFocusState.ts +15 -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/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-DIntkrQl.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -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
|
{
|
|
@@ -26,18 +26,23 @@ import {
|
|
|
26
26
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
27
27
|
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
28
28
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
29
|
-
import { isInstantHold, selectorFromSelection } from "./gsapShared";
|
|
29
|
+
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
30
30
|
import {
|
|
31
31
|
findGsapPositionAnimation,
|
|
32
32
|
pickClosestToPlayhead,
|
|
33
33
|
readGsapPositionFromIframe,
|
|
34
34
|
} from "./gsapPositionDetection";
|
|
35
35
|
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
36
|
+
import {
|
|
37
|
+
animationWritesAnyProperty,
|
|
38
|
+
directEditOutcomeForProperties,
|
|
39
|
+
type GsapEditOutcome,
|
|
40
|
+
} from "./gsapEditOutcome";
|
|
36
41
|
|
|
37
42
|
// Position channels — used to scope the "has a live position tween?" check so a
|
|
38
43
|
// sibling rotation/scale animation never forces a static position hold into the
|
|
39
44
|
// keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
|
|
40
|
-
export const POSITION_CHANNELS = [
|
|
45
|
+
export const POSITION_CHANNELS: string[] = [
|
|
41
46
|
"x",
|
|
42
47
|
"y",
|
|
43
48
|
"xPercent",
|
|
@@ -50,6 +55,10 @@ export const POSITION_CHANNELS = [
|
|
|
50
55
|
"translateX",
|
|
51
56
|
"translateY",
|
|
52
57
|
];
|
|
58
|
+
const POSITION_CHANNEL_SET = new Set<string>(POSITION_CHANNELS);
|
|
59
|
+
|
|
60
|
+
const ROTATION_CHANNELS: string[] = ["rotation", "rotationX", "rotationY", "rotationZ"];
|
|
61
|
+
const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
|
|
53
62
|
|
|
54
63
|
// ── Property-group tween resolution ───────────────────────────────────────
|
|
55
64
|
|
|
@@ -121,11 +130,40 @@ export type { GsapDragCommitCallbacks };
|
|
|
121
130
|
/**
|
|
122
131
|
* Attempt to handle a drag commit via the GSAP script mutation path.
|
|
123
132
|
*
|
|
124
|
-
* Returns
|
|
125
|
-
*
|
|
126
|
-
* exists.
|
|
133
|
+
* Returns an explicit persisted/blocked outcome. Callers must reject blocked
|
|
134
|
+
* outcomes so the gesture layer restores its runtime and overlay drafts.
|
|
127
135
|
*/
|
|
128
136
|
// fallow-ignore-next-line complexity
|
|
137
|
+
async function preflightGsapDragIntercept(
|
|
138
|
+
selection: DomEditSelection,
|
|
139
|
+
animations: GsapAnimation[],
|
|
140
|
+
iframe: HTMLIFrameElement | null,
|
|
141
|
+
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
142
|
+
): Promise<GsapEditOutcome> {
|
|
143
|
+
const selector = selectorFromSelection(selection);
|
|
144
|
+
if (!selector) return { status: "blocked", reason: "no-selector" };
|
|
145
|
+
|
|
146
|
+
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
147
|
+
// The fallback API currently represents both a definitive empty parse and an
|
|
148
|
+
// exhausted fetch failure as `[]`. Keep the selected cache in the preflight
|
|
149
|
+
// set as well: ignoring it would let a transient fetch failure bypass helper /
|
|
150
|
+
// runtime-source ownership and reach a destructive split or property write.
|
|
151
|
+
const allKnownAnimations = [...animations, ...fetchedAnimations];
|
|
152
|
+
const editability = directEditOutcomeForProperties(allKnownAnimations, POSITION_CHANNEL_SET);
|
|
153
|
+
if (editability.status === "blocked") return editability;
|
|
154
|
+
const sourceAnimations = fetchedAnimations.length > 0 ? fetchedAnimations : animations;
|
|
155
|
+
const posAnim = findGsapPositionAnimation(sourceAnimations, selector);
|
|
156
|
+
const hasLivePosition = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
|
|
157
|
+
|
|
158
|
+
if (hasLivePosition && !posAnim) {
|
|
159
|
+
return { status: "blocked", reason: "source-uneditable" };
|
|
160
|
+
}
|
|
161
|
+
if (!posAnim && !writeTargetSelector(selection)) {
|
|
162
|
+
return { status: "blocked", reason: "no-selector" };
|
|
163
|
+
}
|
|
164
|
+
return { status: "persisted" };
|
|
165
|
+
}
|
|
166
|
+
|
|
129
167
|
export async function tryGsapDragIntercept(
|
|
130
168
|
selection: DomEditSelection,
|
|
131
169
|
offset: { x: number; y: number },
|
|
@@ -133,12 +171,20 @@ export async function tryGsapDragIntercept(
|
|
|
133
171
|
iframe: HTMLIFrameElement | null,
|
|
134
172
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
135
173
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
136
|
-
options?: { altKey?: boolean },
|
|
137
|
-
): Promise<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
174
|
+
options?: { altKey?: boolean; preflightOnly?: boolean; preflightPassed?: boolean },
|
|
175
|
+
): Promise<GsapEditOutcome> {
|
|
176
|
+
if (!options?.preflightPassed) {
|
|
177
|
+
const preflight = await preflightGsapDragIntercept(
|
|
178
|
+
selection,
|
|
179
|
+
animations,
|
|
180
|
+
iframe,
|
|
181
|
+
fetchFallbackAnimations,
|
|
182
|
+
);
|
|
183
|
+
if (preflight.status === "blocked" || options?.preflightOnly) return preflight;
|
|
141
184
|
}
|
|
185
|
+
const selector = selectorFromSelection(selection);
|
|
186
|
+
// The preflight above proves this; retain a defensive result for DOM churn.
|
|
187
|
+
if (!selector) return { status: "blocked", reason: "no-selector" };
|
|
142
188
|
|
|
143
189
|
// Self-heal: enforce a single position write BEFORE committing. A corrupted
|
|
144
190
|
// file can carry 2+ conflicting position writes for one selector (e.g. a
|
|
@@ -218,11 +264,11 @@ export async function tryGsapDragIntercept(
|
|
|
218
264
|
commitMutation,
|
|
219
265
|
fetchAnimations: fetchFallbackAnimations,
|
|
220
266
|
});
|
|
221
|
-
return
|
|
267
|
+
return { status: "persisted" };
|
|
222
268
|
}
|
|
223
269
|
|
|
224
270
|
if (!posAnim) {
|
|
225
|
-
return
|
|
271
|
+
return { status: "blocked", reason: "source-uneditable" };
|
|
226
272
|
}
|
|
227
273
|
|
|
228
274
|
// Verify the anim ID is still valid in the current file. The React-state
|
|
@@ -251,7 +297,7 @@ export async function tryGsapDragIntercept(
|
|
|
251
297
|
} else {
|
|
252
298
|
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
|
253
299
|
}
|
|
254
|
-
return
|
|
300
|
+
return { status: "persisted" };
|
|
255
301
|
}
|
|
256
302
|
|
|
257
303
|
// ── Runtime property readers (re-exported for external callers) ───────────
|
|
@@ -267,28 +313,47 @@ export async function tryGsapRotationIntercept(
|
|
|
267
313
|
iframe: HTMLIFrameElement | null,
|
|
268
314
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
269
315
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
270
|
-
): Promise<
|
|
271
|
-
const selector = selectorFromSelection(selection);
|
|
272
|
-
if (!selector) return
|
|
316
|
+
): Promise<GsapEditOutcome> {
|
|
317
|
+
const selector = selectorFromSelection(selection) ?? writeTargetSelector(selection);
|
|
318
|
+
if (!selector) return { status: "blocked", reason: "no-selector" };
|
|
319
|
+
|
|
320
|
+
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
321
|
+
const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
|
|
322
|
+
const editability = directEditOutcomeForProperties(
|
|
323
|
+
[...animations, ...fetchedAnimations],
|
|
324
|
+
ROTATION_CHANNEL_SET,
|
|
325
|
+
);
|
|
326
|
+
if (editability.status === "blocked") return editability;
|
|
327
|
+
const postSplitFetch = workingAnimations.some((animation) => !animation.propertyGroup)
|
|
328
|
+
? fetchFallbackAnimations
|
|
329
|
+
: undefined;
|
|
273
330
|
|
|
274
331
|
// Resolve the rotation-group tween, splitting legacy mixed tweens if needed.
|
|
275
332
|
const resolved = await resolveGroupTween(
|
|
276
333
|
"rotation",
|
|
277
|
-
|
|
334
|
+
workingAnimations,
|
|
278
335
|
selection,
|
|
279
336
|
commitMutation,
|
|
280
|
-
|
|
337
|
+
postSplitFetch,
|
|
281
338
|
);
|
|
282
|
-
const resolvedAnimations = resolved?.animations ??
|
|
339
|
+
const resolvedAnimations = resolved?.animations ?? workingAnimations;
|
|
283
340
|
|
|
284
341
|
// Fallback: legacy heuristic for hand-written scripts
|
|
285
|
-
let anim =
|
|
342
|
+
let anim =
|
|
343
|
+
resolved?.anim && animationWritesAnyProperty(resolved.anim, ROTATION_CHANNEL_SET)
|
|
344
|
+
? resolved.anim
|
|
345
|
+
: null;
|
|
286
346
|
if (!anim) {
|
|
287
|
-
anim =
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
347
|
+
anim =
|
|
348
|
+
workingAnimations.find((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)) ?? null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const liveSelector = selectorFromSelection(selection);
|
|
352
|
+
const hasLiveRotationTween = liveSelector
|
|
353
|
+
? hasNonHoldTweenForElement(iframe, liveSelector, undefined, ROTATION_CHANNELS)
|
|
354
|
+
: false;
|
|
355
|
+
if (!anim && hasLiveRotationTween) {
|
|
356
|
+
return { status: "blocked", reason: "source-uneditable" };
|
|
292
357
|
}
|
|
293
358
|
|
|
294
359
|
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
|
|
@@ -307,7 +372,7 @@ export async function tryGsapRotationIntercept(
|
|
|
307
372
|
commitMutation,
|
|
308
373
|
fetchAnimations: fetchFallbackAnimations,
|
|
309
374
|
});
|
|
310
|
-
return
|
|
375
|
+
return { status: "persisted" };
|
|
311
376
|
}
|
|
312
377
|
|
|
313
378
|
const pct = computeCurrentPercentage(selection, anim);
|
|
@@ -325,7 +390,7 @@ export async function tryGsapRotationIntercept(
|
|
|
325
390
|
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
326
391
|
"Rotate animation",
|
|
327
392
|
);
|
|
328
|
-
return
|
|
393
|
+
return { status: "persisted" };
|
|
329
394
|
}
|
|
330
395
|
|
|
331
396
|
// fallow-ignore-next-line code-duplication
|
|
@@ -363,7 +428,7 @@ export async function tryGsapRotationIntercept(
|
|
|
363
428
|
},
|
|
364
429
|
{ label: `Rotate (keyframe ${pct}%)`, softReload: true },
|
|
365
430
|
);
|
|
366
|
-
return
|
|
431
|
+
return { status: "persisted" };
|
|
367
432
|
}
|
|
368
433
|
|
|
369
434
|
export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|