@hyperframes/studio 0.8.126 → 0.8.128
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-BbbN-wWN.js → hyperframes-player-CKCx0CFA.js} +1 -1
- package/dist/assets/{index-Cll7uTNy.js → index-CF31Pq1E.js} +1 -1
- package/dist/assets/{index-B8kU2xXl.js → index-ClLzyuwb.js} +172 -172
- package/dist/assets/{index-nvLF29ZH.js → index-CwS08gWW.js} +1 -1
- package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
- package/dist/chunk-7UQWVN52.js.map +1 -0
- package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
- package/dist/chunk-DRVWEPO5.js.map +1 -0
- package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
- package/dist/chunk-TDWGXBUT.js.map +1 -0
- package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
- package/dist/index.d.ts +8 -9
- package/dist/index.html +1 -1
- package/dist/index.js +2184 -1745
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/components/TimelineToolbar.tsx +6 -7
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
- package/src/components/editor/MotionPathOverlay.tsx +20 -26
- package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
- package/src/components/editor/domEditingLayers.test.ts +45 -18
- package/src/components/editor/domEditingLayers.ts +11 -9
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/keyframeRetime.ts +11 -4
- package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
- package/src/components/editor/motionPathCommit.ts +58 -0
- package/src/components/editor/motionPathGeometry.test.ts +13 -0
- package/src/components/editor/motionPathGeometry.ts +4 -2
- package/src/components/editor/probeSourceElement.ts +17 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
- package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
- package/src/hooks/gsapDragCommit.ts +41 -6
- package/src/hooks/gsapDragPositionCommit.ts +90 -17
- package/src/hooks/gsapEditOutcome.ts +27 -2
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
- package/src/hooks/gsapMutationClient.ts +35 -7
- package/src/hooks/gsapOvershootEase.test.ts +67 -0
- package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
- package/src/hooks/gsapParsedTween.ts +27 -7
- package/src/hooks/gsapPositionDetection.ts +5 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
- package/src/hooks/gsapRuntimeBridge.ts +7 -1
- package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
- package/src/hooks/gsapRuntimeKeyframes.ts +20 -60
- package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
- package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
- package/src/hooks/gsapShared.test.ts +49 -35
- package/src/hooks/gsapShared.ts +35 -33
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +198 -6
- package/src/hooks/gsapValueAtPlayhead.test.ts +142 -27
- package/src/hooks/gsapValueAtPlayhead.ts +49 -10
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +40 -14
- package/src/hooks/timelineLinkEditPlan.ts +6 -4
- package/src/hooks/timelineMoveAdapter.ts +3 -5
- package/src/hooks/timelineTimingSync.test.ts +83 -125
- package/src/hooks/timelineTimingSync.ts +62 -42
- package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
- package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
- package/src/hooks/useCommitPreflightCapabilities.ts +44 -9
- package/src/hooks/useDomEditPreviewSync.ts +8 -2
- package/src/hooks/useDomSelection.test.ts +27 -0
- package/src/hooks/useDomSelection.ts +12 -13
- package/src/hooks/useDomSelectionTypes.ts +1 -0
- package/src/hooks/useEditHistoryActions.test.tsx +54 -3
- package/src/hooks/useEditHistoryActions.ts +9 -6
- package/src/hooks/useEnableKeyframes.test.ts +20 -15
- package/src/hooks/useEnableKeyframes.ts +54 -71
- package/src/hooks/useGestureCommit.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
- package/src/hooks/useGsapTweenCache.ts +2 -17
- package/src/hooks/usePersistentEditHistory.test.ts +45 -0
- package/src/hooks/usePersistentEditHistory.ts +13 -2
- package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
- package/src/hooks/usePreviewPersistence.ts +26 -4
- package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
- package/src/hooks/useTimelineDeleteOps.ts +5 -13
- package/src/hooks/useTimelineEditing.test.tsx +63 -4
- package/src/hooks/useTimelineEditing.ts +21 -295
- package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
- package/src/hooks/useTimelineGroupEditing.ts +15 -38
- package/src/player/components/Timeline.test.ts +12 -4
- package/src/player/components/TimelineClip.tsx +3 -0
- package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
- package/src/player/components/TimelineLanes.test.tsx +61 -5
- package/src/player/components/TimelineLanes.tsx +20 -28
- package/src/player/components/TimelinePropertyLanes.tsx +10 -1
- package/src/player/components/timelineCallbacks.ts +1 -4
- package/src/player/components/timelineClipDragCommit.test.ts +31 -0
- package/src/player/components/timelineClipDragCommit.ts +39 -28
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
- package/src/player/components/timelineClipGestureHandlers.ts +2 -20
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineGroupResizeCommit.ts +10 -9
- package/src/player/components/timelineLinkSelection.ts +1 -35
- package/src/player/components/timelineOptimisticRevision.ts +7 -4
- package/src/player/components/trackHeaderLaneState.ts +9 -2
- package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
- package/src/player/components/trackHeaderLaneValues.ts +54 -13
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
- package/src/player/components/useTimelineClipDrag.ts +0 -3
- package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
- package/src/player/store/batchElementUpdates.test.ts +43 -0
- package/src/player/store/batchElementUpdates.ts +9 -0
- package/src/player/store/elementPatchQueue.ts +35 -0
- package/src/player/store/playerStore.ts +5 -6
- package/src/utils/domEditHelpers.ts +5 -0
- package/src/utils/globalTimeCompiler.test.ts +0 -4
- package/src/utils/globalTimeCompiler.ts +6 -4
- package/src/utils/gsapKeyframeEases.ts +79 -0
- package/src/utils/sameData.ts +9 -0
- package/src/utils/studioFileHistory.ts +1 -0
- package/src/utils/studioPendingEdits.ts +15 -1
- package/dist/chunk-246VB2G3.js.map +0 -1
- package/dist/chunk-3F47EFCT.js.map +0 -1
- package/dist/chunk-5EKWJZLM.js.map +0 -1
- /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
1
|
+
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
3
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
isTimeWithinTween,
|
|
6
|
+
resolveTweenDuration,
|
|
7
|
+
resolveTweenStart,
|
|
8
|
+
} from "../utils/globalTimeCompiler";
|
|
9
|
+
import { KEYFRAME_PCT_MATCH, playsNear } from "./gsapShared";
|
|
10
|
+
import { progressAtTime, runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
|
|
6
11
|
import { roundTo3 } from "../utils/rounding";
|
|
7
12
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
8
13
|
import {
|
|
@@ -19,7 +24,8 @@ import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
|
|
|
19
24
|
* server takes a replace-with-keyframes list verbatim, so an append-only build
|
|
20
25
|
* could hand it two keyframes a fraction of a percent apart. The invariant lives
|
|
21
26
|
* here rather than in each caller's own pre-check, which is how the two callers
|
|
22
|
-
* ended up with different tolerances in the first place.
|
|
27
|
+
* ended up with different tolerances in the first place. Arc writes are linear, so each
|
|
28
|
+
* keyframe moves to the time GSAP reached it and `percentage` is a time percentage.
|
|
23
29
|
*/
|
|
24
30
|
export function buildTemporalArcKeyframes(
|
|
25
31
|
anim: GsapAnimation,
|
|
@@ -27,7 +33,7 @@ export function buildTemporalArcKeyframes(
|
|
|
27
33
|
properties: Record<string, number>,
|
|
28
34
|
) {
|
|
29
35
|
return [
|
|
30
|
-
...(anim
|
|
36
|
+
...linearArcKeyframes(anim)
|
|
31
37
|
.filter((keyframe) => Math.abs(keyframe.percentage - percentage) > KEYFRAME_PCT_MATCH)
|
|
32
38
|
.map((keyframe) => ({
|
|
33
39
|
percentage: keyframe.percentage,
|
|
@@ -38,6 +44,46 @@ export function buildTemporalArcKeyframes(
|
|
|
38
44
|
].sort((a, b) => a.percentage - b.percentage);
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
/** An arc's keyframes at the time percentages GSAP plays them, for a write with `ease: "none"`. */
|
|
48
|
+
export function linearArcKeyframes(anim: GsapAnimation): GsapPercentageKeyframe[] {
|
|
49
|
+
const runEase = runEaseOf(anim);
|
|
50
|
+
return (anim.keyframes?.keyframes ?? []).map((keyframe) => ({
|
|
51
|
+
...keyframe,
|
|
52
|
+
percentage: roundTo3(timeAtProgress(runEase, keyframe.percentage)),
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Grow a keyframe tween's range to a playhead outside it and add a keyframe there; every
|
|
57
|
+
* existing keyframe keeps its absolute time under `writeEase`, the run ease the write keeps. */
|
|
58
|
+
export function buildExtendedKeyframes(
|
|
59
|
+
anim: GsapAnimation,
|
|
60
|
+
currentTime: number,
|
|
61
|
+
position: Record<string, number>,
|
|
62
|
+
sourceDuration = resolveTweenDuration(anim),
|
|
63
|
+
writeEase = runEaseOf(anim),
|
|
64
|
+
): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
|
|
65
|
+
const oldStart = resolveTweenStart(anim) ?? 0;
|
|
66
|
+
const oldDuration = sourceDuration;
|
|
67
|
+
const newStart = Math.min(oldStart, currentTime);
|
|
68
|
+
const newEnd = Math.max(oldStart + oldDuration, currentTime);
|
|
69
|
+
const newDuration = roundTo3(newEnd - newStart);
|
|
70
|
+
const oldRunEase = runEaseOf(anim);
|
|
71
|
+
const toPct = (absoluteTime: number) => {
|
|
72
|
+
if (newDuration <= 0) return 0;
|
|
73
|
+
const progress = progressAtTime(writeEase, ((absoluteTime - newStart) / newDuration) * 100);
|
|
74
|
+
return Math.max(0, Math.min(100, Math.round(progress * 10) / 10));
|
|
75
|
+
};
|
|
76
|
+
const stops = anim.keyframes?.keyframes ?? [];
|
|
77
|
+
const rescaled: GsapPercentageKeyframe[] = stops.map((stop) => ({
|
|
78
|
+
percentage: toPct(oldStart + (timeAtProgress(oldRunEase, stop.percentage) / 100) * oldDuration),
|
|
79
|
+
properties: stop.properties,
|
|
80
|
+
...(stop.ease ? { ease: stop.ease } : {}),
|
|
81
|
+
}));
|
|
82
|
+
const added: GsapPercentageKeyframe = { percentage: toPct(currentTime), properties: position };
|
|
83
|
+
const keyframes = [...rescaled, added].sort((a, b) => a.percentage - b.percentage);
|
|
84
|
+
return { position: roundTo3(newStart), duration: newDuration, keyframes };
|
|
85
|
+
}
|
|
86
|
+
|
|
41
87
|
/** commitGsapPositionFromDrag's refusal, decided without writing. */
|
|
42
88
|
export function gsapPositionFromDragOutcome(
|
|
43
89
|
selection: DomEditSelection,
|
|
@@ -87,15 +133,45 @@ export async function commitGsapPositionFromDrag(
|
|
|
87
133
|
};
|
|
88
134
|
|
|
89
135
|
if (anim.arcPath?.enabled) {
|
|
90
|
-
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
136
|
+
const { activeKeyframePct, currentTime, setActiveKeyframePct } = usePlayerStore.getState();
|
|
137
|
+
const tweenStart = resolveTweenStart(anim);
|
|
138
|
+
const tweenDuration = resolveTweenDuration(anim);
|
|
139
|
+
if (
|
|
140
|
+
activeKeyframePct === null &&
|
|
141
|
+
tweenStart !== null &&
|
|
142
|
+
!isTimeWithinTween(currentTime, tweenStart, tweenDuration)
|
|
143
|
+
) {
|
|
144
|
+
const extended = buildExtendedKeyframes(
|
|
145
|
+
anim,
|
|
146
|
+
currentTime,
|
|
147
|
+
{ x: newX, y: newY },
|
|
148
|
+
tweenDuration,
|
|
149
|
+
"none",
|
|
150
|
+
);
|
|
151
|
+
await callbacks.commitMutation(
|
|
152
|
+
selection,
|
|
153
|
+
{
|
|
154
|
+
type: "replace-with-keyframes",
|
|
155
|
+
animationId: anim.id,
|
|
156
|
+
targetSelector: anim.targetSelector,
|
|
157
|
+
...extended,
|
|
158
|
+
ease: "none",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
label: "Move layer (new keyframe)",
|
|
162
|
+
keyframeAction: "add",
|
|
163
|
+
softReload: true,
|
|
164
|
+
beforeReload: restoreOffset,
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
return { status: "persisted" };
|
|
168
|
+
}
|
|
91
169
|
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
92
170
|
const keyframes = anim.keyframes?.keyframes ?? [];
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
const pointIndex = keyframes.findIndex(
|
|
97
|
-
(kf) => Math.abs(kf.percentage - pct) <= KEYFRAME_PCT_MATCH,
|
|
98
|
-
);
|
|
171
|
+
// A drag counts as on a waypoint when it plays within KEYFRAME_PCT_MATCH of it,
|
|
172
|
+
// so landing a fraction of a percent off an authored waypoint updates that point
|
|
173
|
+
// instead of appending a new one.
|
|
174
|
+
const pointIndex = keyframes.findIndex((kf) => playsNear(anim, kf.percentage, pct));
|
|
99
175
|
if (pointIndex >= 0) {
|
|
100
176
|
await callbacks.commitMutation(
|
|
101
177
|
selection,
|
|
@@ -113,13 +189,10 @@ export async function commitGsapPositionFromDrag(
|
|
|
113
189
|
return { status: "persisted" };
|
|
114
190
|
}
|
|
115
191
|
|
|
116
|
-
const tweenStart = resolveTweenStart(anim);
|
|
117
|
-
// Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s
|
|
118
|
-
// default here would collapse a duration-less arc's window on drag.
|
|
119
|
-
const tweenDuration = resolveEditableTweenDuration(anim, selection);
|
|
120
192
|
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2)
|
|
121
193
|
return { status: "persisted" };
|
|
122
|
-
const
|
|
194
|
+
const timePct = roundTo3(timeAtProgress(runEaseOf(anim), pct));
|
|
195
|
+
const temporalKeyframes = buildTemporalArcKeyframes(anim, timePct, { x: newX, y: newY });
|
|
123
196
|
await callbacks.commitMutation(
|
|
124
197
|
selection,
|
|
125
198
|
{
|
|
@@ -15,7 +15,7 @@ export type GsapEditBlockReason =
|
|
|
15
15
|
* true for `provenance-runtime-dynamic`; the others are parser or source-match
|
|
16
16
|
* limits, where the animation may well be plain authored source.
|
|
17
17
|
*
|
|
18
|
-
* Telemetry
|
|
18
|
+
* Telemetry, plus the sharper message a few details get (GSAP_EDIT_DETAIL_COPY).
|
|
19
19
|
*/
|
|
20
20
|
export type GsapEditBlockDetail =
|
|
21
21
|
| "provenance-runtime-dynamic"
|
|
@@ -33,6 +33,12 @@ export type GsapEditBlockDetail =
|
|
|
33
33
|
export type PlayheadEditRefusal =
|
|
34
34
|
| "eased-keyframes"
|
|
35
35
|
| "simple-array-keyframes"
|
|
36
|
+
| "array-step-delay"
|
|
37
|
+
| "array-step-callback"
|
|
38
|
+
| "array-step-config"
|
|
39
|
+
| "array-step-computed"
|
|
40
|
+
| "array-step-relative"
|
|
41
|
+
| "array-step-random"
|
|
36
42
|
| "unknown-ease"
|
|
37
43
|
| "implicit-end-unknown"
|
|
38
44
|
| "not-a-tween"
|
|
@@ -74,12 +80,31 @@ export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
|
|
|
74
80
|
"These layers are animated in different files. Move each file's layers separately.",
|
|
75
81
|
};
|
|
76
82
|
|
|
83
|
+
const STEP_LIST = "This animation's keyframes have a step";
|
|
84
|
+
const IN_CODE = "Edit them in the Code tab.";
|
|
85
|
+
/** A refusal a person can act on more precisely than its reason's message: what blocks it, then what to do. */
|
|
86
|
+
const GSAP_EDIT_DETAIL_COPY: Partial<Record<GsapEditBlockDetail, string>> = {
|
|
87
|
+
"array-step-delay": `${STEP_LIST} with its own delay, which Studio can't keep while it edits them. ${IN_CODE}`,
|
|
88
|
+
"array-step-callback": `${STEP_LIST} that runs code as it plays (like onComplete), which an edit would run again. ${IN_CODE}`,
|
|
89
|
+
"array-step-config": `${STEP_LIST} with its own tween setting (like repeat or stagger), which Studio can't keep while it edits them. ${IN_CODE}`,
|
|
90
|
+
"array-step-computed": `${STEP_LIST} whose value comes from code, which Studio can't keep while it edits them. ${IN_CODE}`,
|
|
91
|
+
"array-step-relative": `${STEP_LIST} with a relative value (like "+=40"), which an edit would apply again. ${IN_CODE}`,
|
|
92
|
+
"array-step-random": `${STEP_LIST} with a random() value, which an edit would roll again. ${IN_CODE}`,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export function gsapEditBlockMessage(
|
|
96
|
+
reason: GsapEditBlockReason,
|
|
97
|
+
detail?: GsapEditBlockDetail,
|
|
98
|
+
): string {
|
|
99
|
+
return (detail && GSAP_EDIT_DETAIL_COPY[detail]) ?? GSAP_EDIT_BLOCK_COPY[reason];
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
export class GsapEditBlockedError extends Error {
|
|
78
103
|
constructor(
|
|
79
104
|
readonly reason: GsapEditBlockReason,
|
|
80
105
|
readonly detail?: GsapEditBlockDetail,
|
|
81
106
|
) {
|
|
82
|
-
super(
|
|
107
|
+
super(gsapEditBlockMessage(reason, detail));
|
|
83
108
|
this.name = "GsapEditBlockedError";
|
|
84
109
|
}
|
|
85
110
|
}
|
|
@@ -78,6 +78,56 @@ describe("non-string cache keys", () => {
|
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
describe("replaceKeyframeCacheForFile re-reads", () => {
|
|
82
|
+
it("publishes nothing when a file re-reads to the same entries, and the change when one differs", () => {
|
|
83
|
+
const write = (x: number) =>
|
|
84
|
+
replaceKeyframeCacheForFile(
|
|
85
|
+
"comp.html",
|
|
86
|
+
new Map([
|
|
87
|
+
["box", { format: "percentage", keyframes: [{ percentage: 0, properties: { x } }] }],
|
|
88
|
+
]),
|
|
89
|
+
new Map([["box", [animWithKeyframes("box")]]]),
|
|
90
|
+
);
|
|
91
|
+
write(0);
|
|
92
|
+
let notified = 0;
|
|
93
|
+
const stop = usePlayerStore.subscribe(() => notified++);
|
|
94
|
+
write(0);
|
|
95
|
+
expect(notified).toBe(0);
|
|
96
|
+
write(5);
|
|
97
|
+
stop();
|
|
98
|
+
expect(notified).toBe(1);
|
|
99
|
+
expect(cache().get("comp.html#box")?.keyframes[0]?.properties).toEqual({ x: 5 });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("leaves a sub-composition's entries alone when index.html re-reads", () => {
|
|
103
|
+
const one = (id: string) => new Map([[id, entry()]]);
|
|
104
|
+
const anims = (id: string) => new Map([[id, [animWithKeyframes(id)]]]);
|
|
105
|
+
replaceKeyframeCacheForFile("comp.html", one("box"), anims("box"));
|
|
106
|
+
replaceKeyframeCacheForFile("index.html", one("hero"), anims("hero"));
|
|
107
|
+
let notified = 0;
|
|
108
|
+
const stop = usePlayerStore.subscribe(() => notified++);
|
|
109
|
+
replaceKeyframeCacheForFile("index.html", one("hero"), anims("hero"));
|
|
110
|
+
stop();
|
|
111
|
+
|
|
112
|
+
expect(notified).toBe(0);
|
|
113
|
+
for (const key of ["comp.html#box", "index.html#box", "box", "index.html#hero", "hero"]) {
|
|
114
|
+
expect(cache().has(key)).toBe(true);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("still drops index.html's own element that shares an id with a sub-composition", () => {
|
|
119
|
+
const one = (id: string) => new Map([[id, entry()]]);
|
|
120
|
+
const anims = (id: string) => new Map([[id, [animWithKeyframes(id)]]]);
|
|
121
|
+
const own = { format: "percentage", keyframes: [{ percentage: 0, properties: { x: 9 } }] };
|
|
122
|
+
replaceKeyframeCacheForFile("comp.html", one("title"), anims("title"));
|
|
123
|
+
replaceKeyframeCacheForFile("index.html", new Map([["title", own]]), anims("title"));
|
|
124
|
+
replaceKeyframeCacheForFile("index.html", new Map(), new Map());
|
|
125
|
+
|
|
126
|
+
expect(cache().has("index.html#title")).toBe(false);
|
|
127
|
+
expect(cache().has("comp.html#title")).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
81
131
|
describe("clearKeyframeCacheForElement", () => {
|
|
82
132
|
it("drops the prefixed, index.html fallback, and bare key for a non-index source", () => {
|
|
83
133
|
seed("comp.html#box");
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
|
7
7
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
8
|
+
import { sameData } from "../utils/sameData";
|
|
8
9
|
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
|
|
9
10
|
import {
|
|
10
11
|
deduplicateKeyframes,
|
|
@@ -18,6 +19,14 @@ export interface KeyframeCacheDraft {
|
|
|
18
19
|
gsapAnimations: Map<string, GsapAnimation[]>;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// A rebuilt entry equal to the one shown keeps its object, so re-reading an unchanged file publishes nothing.
|
|
23
|
+
function keepEqualEntries<V>(before: ReadonlyMap<string, V>, draft: Map<string, V>): void {
|
|
24
|
+
for (const [key, value] of draft) {
|
|
25
|
+
const shown = before.get(key);
|
|
26
|
+
if (shown !== undefined && shown !== value && sameData(shown, value)) draft.set(key, shown);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
function sameEntries<V>(before: ReadonlyMap<string, V>, after: ReadonlyMap<string, V>): boolean {
|
|
22
31
|
if (before.size !== after.size) return false;
|
|
23
32
|
for (const [key, value] of before) {
|
|
@@ -42,6 +51,8 @@ export function publishKeyframeCache(edit: (draft: KeyframeCacheDraft) => void):
|
|
|
42
51
|
gsapAnimations: new Map(gsapAnimations),
|
|
43
52
|
};
|
|
44
53
|
edit(draft);
|
|
54
|
+
keepEqualEntries(keyframeCache, draft.keyframeCache);
|
|
55
|
+
keepEqualEntries(gsapAnimations, draft.gsapAnimations);
|
|
45
56
|
if (
|
|
46
57
|
sameEntries(keyframeCache, draft.keyframeCache) &&
|
|
47
58
|
sameEntries(gsapAnimations, draft.gsapAnimations)
|
|
@@ -175,8 +186,16 @@ function cachedElementIdsForFile(
|
|
|
175
186
|
const sfPrefix = `${sourceFile}#`;
|
|
176
187
|
const ids = new Set<string>();
|
|
177
188
|
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
|
|
178
|
-
if (
|
|
179
|
-
|
|
189
|
+
if (key.startsWith(sfPrefix)) ids.add(key.slice(sfPrefix.length));
|
|
190
|
+
}
|
|
191
|
+
if (sourceFile !== "index.html") return ids;
|
|
192
|
+
// Another file's element also writes the `index.html#id` fallback, as the very same entry object.
|
|
193
|
+
for (const [key, entry] of keyframeCache) {
|
|
194
|
+
const hash = key.indexOf("#");
|
|
195
|
+
const id = key.slice(hash + 1);
|
|
196
|
+
if (hash > 0 && !key.startsWith(sfPrefix) && keyframeCache.get(sfPrefix + id) === entry) {
|
|
197
|
+
ids.delete(id);
|
|
198
|
+
}
|
|
180
199
|
}
|
|
181
200
|
return ids;
|
|
182
201
|
}
|
|
@@ -88,9 +88,11 @@ function readMutationError(value: unknown, fallback: string): string {
|
|
|
88
88
|
return fallback;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
type GsapMutationRoute = "gsap-mutations" | "gsap-mutations-batch";
|
|
92
|
+
|
|
91
93
|
export function requestGsapMutation(
|
|
92
94
|
projectId: string,
|
|
93
|
-
route:
|
|
95
|
+
route: GsapMutationRoute,
|
|
94
96
|
filePath: string,
|
|
95
97
|
body: unknown,
|
|
96
98
|
): Promise<Response> {
|
|
@@ -104,23 +106,49 @@ export function requestGsapMutation(
|
|
|
104
106
|
);
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
|
|
109
|
+
async function postOwnedGsapMutation(
|
|
110
|
+
route: GsapMutationRoute,
|
|
108
111
|
projectId: string,
|
|
109
112
|
filePath: string,
|
|
110
|
-
|
|
113
|
+
body: Record<string, unknown>,
|
|
111
114
|
fallback: string,
|
|
112
115
|
): Promise<GsapMutationStatus> {
|
|
113
116
|
let response: Response;
|
|
114
117
|
try {
|
|
115
|
-
response = await requestGsapMutation(projectId,
|
|
118
|
+
response = await requestGsapMutation(projectId, route, filePath, body);
|
|
116
119
|
} catch (error) {
|
|
117
120
|
throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
|
|
118
121
|
cause: error,
|
|
119
122
|
});
|
|
120
123
|
}
|
|
121
|
-
const
|
|
124
|
+
const payload: unknown = await response.json().catch(() => null);
|
|
122
125
|
if (!response.ok) {
|
|
123
|
-
throw new GsapPreviewConvergenceError(readMutationError(
|
|
126
|
+
throw new GsapPreviewConvergenceError(readMutationError(payload, fallback));
|
|
124
127
|
}
|
|
125
|
-
return readMutationStatus(
|
|
128
|
+
return readMutationStatus(payload);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function postGsapMutation(
|
|
132
|
+
projectId: string,
|
|
133
|
+
filePath: string,
|
|
134
|
+
mutation: Record<string, unknown>,
|
|
135
|
+
fallback: string,
|
|
136
|
+
): Promise<GsapMutationStatus> {
|
|
137
|
+
return postOwnedGsapMutation("gsap-mutations", projectId, filePath, mutation, fallback);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Every mutation in one request: the file is parsed and written once, with one ownership pair. */
|
|
141
|
+
export function postGsapMutations(
|
|
142
|
+
projectId: string,
|
|
143
|
+
filePath: string,
|
|
144
|
+
mutations: readonly Record<string, unknown>[],
|
|
145
|
+
fallback: string,
|
|
146
|
+
): Promise<GsapMutationStatus> {
|
|
147
|
+
return postOwnedGsapMutation(
|
|
148
|
+
"gsap-mutations-batch",
|
|
149
|
+
projectId,
|
|
150
|
+
filePath,
|
|
151
|
+
{ mutations },
|
|
152
|
+
fallback,
|
|
153
|
+
);
|
|
126
154
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
|
|
3
|
+
import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { resolveKeyframeRetime } from "../components/editor/keyframeRetime";
|
|
6
|
+
import { absoluteToPercentageForAnimation } from "../utils/globalTimeCompiler";
|
|
7
|
+
import { runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
|
|
8
|
+
import { buildExtendedKeyframes } from "./gsapDragPositionCommit";
|
|
9
|
+
import { keyframeEases } from "./gsapShared";
|
|
10
|
+
|
|
11
|
+
const script = (ease: string) =>
|
|
12
|
+
`const tl = gsap.timeline({ paused: true });\ntl.to("#x", { keyframes: { "0%": { x: 0 }, "50%": { x: 600 }, "100%": { x: 1200 } }, duration: 2, ease: "${ease}" }, 1);`;
|
|
13
|
+
|
|
14
|
+
const percentages = (anim: GsapAnimation) => anim.keyframes!.keyframes.map((k) => k.percentage);
|
|
15
|
+
const distinctInRange = (pcts: number[]) =>
|
|
16
|
+
pcts.every((p, i) => p >= 0 && p <= 100 && (i === 0 || p > pcts[i - 1]!));
|
|
17
|
+
|
|
18
|
+
describe.each(["back.out(1.7)", "back.in(1.7)", "elastic.out(1, 0.3)", "steps(4)"])(
|
|
19
|
+
"an outer %s, which does not map time to progress one to one",
|
|
20
|
+
(ease) => {
|
|
21
|
+
const src = script(ease);
|
|
22
|
+
const anim = parseGsapScriptAcorn(src).animations[0]!;
|
|
23
|
+
|
|
24
|
+
it("adding a keyframe past the end keeps every keyframe, distinct and in range, after a reparse", () => {
|
|
25
|
+
const out = buildExtendedKeyframes(anim, 5, { x: 1500 });
|
|
26
|
+
const written = replaceTweenWithKeyframesInScript(src, anim.id, {
|
|
27
|
+
targetSelector: "#x",
|
|
28
|
+
...keyframeEases(anim),
|
|
29
|
+
...out,
|
|
30
|
+
})!;
|
|
31
|
+
const back = parseGsapScriptAcorn(written).animations[0]!;
|
|
32
|
+
expect(distinctInRange(percentages(back))).toBe(true);
|
|
33
|
+
expect(back.keyframes!.keyframes.map((k) => k.properties.x)).toEqual([0, 600, 1200, 1500]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("a keyframe added inside the tween lands within 0-100%", () => {
|
|
37
|
+
const pct = absoluteToPercentageForAnimation(1 + 0.7 * 2, anim)!;
|
|
38
|
+
expect(pct).toBeGreaterThanOrEqual(0);
|
|
39
|
+
expect(pct).toBeLessThanOrEqual(100);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("dragging the last keyframe later keeps every keyframe distinct and in range", () => {
|
|
43
|
+
const retime = resolveKeyframeRetime({
|
|
44
|
+
keyframes: anim.keyframes!.keyframes,
|
|
45
|
+
draggedTweenPct: 100,
|
|
46
|
+
tweenStart: 1,
|
|
47
|
+
tweenDuration: 2,
|
|
48
|
+
dropAbsTime: 4,
|
|
49
|
+
runEase: runEaseOf(anim),
|
|
50
|
+
});
|
|
51
|
+
expect(retime.kind).toBe("resize");
|
|
52
|
+
expect(distinctInRange(retime.pctRemap!.map((r) => r.to))).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
it("extending a tween under steps(4) loses no keyframe", () => {
|
|
58
|
+
const src = `const tl = gsap.timeline({ paused: true });\ntl.to("#x", { keyframes: { "0%": { x: 0 }, "25%": { x: 60 }, "60%": { x: 120 }, "100%": { x: 30 } }, duration: 2, ease: "steps(4)" }, 1);`;
|
|
59
|
+
const anim = parseGsapScriptAcorn(src).animations[0]!;
|
|
60
|
+
const out = buildExtendedKeyframes(anim, 4, { x: 999 });
|
|
61
|
+
expect(distinctInRange(out.keyframes.map((k) => k.percentage))).toBe(true);
|
|
62
|
+
expect(out.keyframes.map((k) => k.properties.x)).toEqual([0, 60, 120, 30, 999]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("still times keyframes through an outer ease that rises one to one", () => {
|
|
66
|
+
expect(timeAtProgress("power2.in", 50)).toBeCloseTo(79.37, 1);
|
|
67
|
+
});
|
|
@@ -22,7 +22,7 @@ export function liveTween(
|
|
|
22
22
|
vars: tween.vars,
|
|
23
23
|
_from: from,
|
|
24
24
|
...(head && { _pt: { d: { _pt: head } } }),
|
|
25
|
-
...(parts && { timeline: { getChildren: () => parts } }),
|
|
25
|
+
...(parts && { timeline: { getChildren: () => parts, duration: () => tween.duration } }),
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -47,6 +47,33 @@ export function previewWith(
|
|
|
47
47
|
export const tween = (fields: Partial<GsapAnimation>): GsapAnimation =>
|
|
48
48
|
({ targetSelector: "#box", propertyGroup: "position", ...fields }) as GsapAnimation;
|
|
49
49
|
|
|
50
|
+
/** `#box`'s position as a keyframe step list, parsed and run by GSAP (step `[duration, props]`). */
|
|
51
|
+
export function boxSteps(steps: Array<[number, Record<string, number | string>]>) {
|
|
52
|
+
const duration = steps.reduce((sum, [d]) => sum + d, 0);
|
|
53
|
+
let end = 0;
|
|
54
|
+
const keyframes = steps.map(([d, properties]) => {
|
|
55
|
+
end += d;
|
|
56
|
+
return { percentage: Math.round((end / duration) * 1000) / 10, properties };
|
|
57
|
+
});
|
|
58
|
+
const keys = tween({
|
|
59
|
+
id: "#box-to-0-position",
|
|
60
|
+
method: "to",
|
|
61
|
+
properties: {},
|
|
62
|
+
resolvedStart: 0,
|
|
63
|
+
duration,
|
|
64
|
+
keyframes: { format: "object-array", keyframes },
|
|
65
|
+
});
|
|
66
|
+
let start = 0;
|
|
67
|
+
const parts = steps.map(([d]) => {
|
|
68
|
+
const at = start;
|
|
69
|
+
start += d;
|
|
70
|
+
return { startTime: () => at, duration: () => d };
|
|
71
|
+
});
|
|
72
|
+
const live = (el: Element) =>
|
|
73
|
+
liveTween(el, { start: 0, duration, vars: { keyframes: [] } }, { parts });
|
|
74
|
+
return { keys, live };
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
/** A `to` tween on `#el` from 0, grouped as the parser would; duration 0 is an immediate-render hold. */
|
|
51
78
|
export function elTween(
|
|
52
79
|
id: string,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
2
|
import { elementTargets } from "../utils/elementGsap";
|
|
3
3
|
import { resolveTweenStart } from "../utils/globalTimeCompiler";
|
|
4
|
+
import { KEYFRAME_PCT_MATCH } from "./gsapShared";
|
|
4
5
|
import type { ImplicitEndValue } from "./gsapValueAtPlayhead";
|
|
5
6
|
import type { ReadTween } from "./gsapRuntimeKeyframes";
|
|
6
7
|
|
|
@@ -23,7 +24,7 @@ interface ParsedTween {
|
|
|
23
24
|
seek?: (time: number, suppressEvents?: boolean) => unknown;
|
|
24
25
|
getChildren?: (nested?: boolean, tweens?: boolean, timelines?: boolean) => ParsedTween[];
|
|
25
26
|
};
|
|
26
|
-
timeline?: { getChildren?: () => ParsedTween[] };
|
|
27
|
+
timeline?: { getChildren?: () => ParsedTween[]; duration?: () => number };
|
|
27
28
|
targets?: () => unknown[];
|
|
28
29
|
startTime?: () => number;
|
|
29
30
|
duration?: () => number;
|
|
@@ -246,14 +247,33 @@ const BUILT_IN_EASES = [
|
|
|
246
247
|
export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
|
|
247
248
|
const data = anim.keyframes;
|
|
248
249
|
const parts = tween?.timeline?.getChildren?.() ?? [];
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (ends.some((end) => !Number.isFinite(end))) return anim;
|
|
250
|
+
if (data?.format !== "object-array" || parts.length !== data.keyframes.length) return anim;
|
|
251
|
+
const ends = parts.map((part) => (part.startTime?.() ?? 0) + (part.duration?.() ?? 0));
|
|
252
|
+
const total = Math.max(...ends);
|
|
253
|
+
if (!(total > 0) || ends.some((end) => !Number.isFinite(end))) return anim;
|
|
254
254
|
const keyframes = data.keyframes.map((kf, i) => ({
|
|
255
255
|
...kf,
|
|
256
|
-
percentage: Math.round(ends[i]! * 100000) / 1000,
|
|
256
|
+
percentage: Math.round((ends[i]! / total) * 100000) / 1000,
|
|
257
257
|
}));
|
|
258
258
|
return { ...anim, keyframes: { ...data, keyframes } };
|
|
259
259
|
}
|
|
260
|
+
|
|
261
|
+
/** The keyframe nearest `pct` within {@link KEYFRAME_PCT_MATCH}, or -1: two steps can sit under 1% apart. */
|
|
262
|
+
export function nearestKeyframeIndex(keyframes: { percentage: number }[], pct: number): number {
|
|
263
|
+
let best = -1;
|
|
264
|
+
keyframes.forEach((kf, i) => {
|
|
265
|
+
const off = Math.abs(kf.percentage - pct);
|
|
266
|
+
if (
|
|
267
|
+
off <= KEYFRAME_PCT_MATCH &&
|
|
268
|
+
(best < 0 || off < Math.abs(keyframes[best]!.percentage - pct))
|
|
269
|
+
)
|
|
270
|
+
best = i;
|
|
271
|
+
});
|
|
272
|
+
return best;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function exactKeyframePct(anim: GsapAnimation, tween: ParsedTween | null, pct: number) {
|
|
276
|
+
const authored = anim.keyframes?.keyframes ?? [];
|
|
277
|
+
const i = nearestKeyframeIndex(authored, pct);
|
|
278
|
+
return withExactStepTimes(anim, tween).keyframes?.keyframes[i]?.percentage ?? pct;
|
|
279
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
9
9
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
10
10
|
import { getIframeGsap, KEYFRAME_PCT_MATCH, queryIframeElement } from "./gsapShared";
|
|
11
|
+
import { runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
|
|
11
12
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
12
13
|
|
|
13
14
|
// fallow-ignore-next-line complexity
|
|
@@ -60,7 +61,7 @@ export function findGsapPositionAnimation(
|
|
|
60
61
|
|
|
61
62
|
function playheadProximity(a: GsapAnimation, currentTime: number): number {
|
|
62
63
|
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
|
|
63
|
-
const dur = a
|
|
64
|
+
const dur = resolveTweenDuration(a);
|
|
64
65
|
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) return 50;
|
|
65
66
|
return -Math.round(Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5);
|
|
66
67
|
}
|
|
@@ -71,7 +72,9 @@ function statesValueAt(anim: GsapAnimation, time: number): boolean {
|
|
|
71
72
|
if (!(duration > 0)) return false;
|
|
72
73
|
const pct = ((time - (resolveTweenStart(anim) ?? 0)) / duration) * 100;
|
|
73
74
|
const near = (keyframe: number) => Math.abs(keyframe - pct) <= KEYFRAME_PCT_MATCH;
|
|
74
|
-
|
|
75
|
+
const runEase = runEaseOf(anim);
|
|
76
|
+
if (anim.keyframes)
|
|
77
|
+
return anim.keyframes.keyframes.some((kf) => near(timeAtProgress(runEase, kf.percentage)));
|
|
75
78
|
if (anim.method === "from") return near(0);
|
|
76
79
|
return near(100) || (anim.method === "fromTo" && near(0));
|
|
77
80
|
}
|
|
@@ -582,6 +582,41 @@ describe("tryGsapDragIntercept — motion paths", () => {
|
|
|
582
582
|
);
|
|
583
583
|
});
|
|
584
584
|
|
|
585
|
+
it("adds a node at a playhead past a duration-less arc, keeping the other nodes' times", async () => {
|
|
586
|
+
usePlayerStore.setState({
|
|
587
|
+
autoKeyframeEnabled: true,
|
|
588
|
+
activeKeyframePct: null,
|
|
589
|
+
currentTime: 15.9,
|
|
590
|
+
});
|
|
591
|
+
const durationless = { ...motionPathAnim, duration: undefined } as GsapAnimation;
|
|
592
|
+
const commitMutation = vi.fn();
|
|
593
|
+
await tryGsapDragIntercept(
|
|
594
|
+
selection,
|
|
595
|
+
{ x: -50, y: 30 },
|
|
596
|
+
[durationless],
|
|
597
|
+
fakeIframe("puck-b", [
|
|
598
|
+
{ ...liveTween, vars: { motionPath: { path: [] } }, duration: () => 0.5 },
|
|
599
|
+
]),
|
|
600
|
+
commitMutation,
|
|
601
|
+
);
|
|
602
|
+
// GSAP plays it 12.17-12.67 s; the nodes stay at 12.17, 12.42 and 12.67 s.
|
|
603
|
+
expect(commitMutation).toHaveBeenCalledWith(
|
|
604
|
+
selection,
|
|
605
|
+
expect.objectContaining({
|
|
606
|
+
type: "replace-with-keyframes",
|
|
607
|
+
position: 12.17,
|
|
608
|
+
duration: 3.73,
|
|
609
|
+
keyframes: [
|
|
610
|
+
{ percentage: 0, properties: { x: -184, y: 326 } },
|
|
611
|
+
{ percentage: 6.7, properties: { x: 416, y: 804 } },
|
|
612
|
+
{ percentage: 13.4, properties: { x: 796, y: 237 } },
|
|
613
|
+
{ percentage: 100, properties: { x: -50, y: 30 } },
|
|
614
|
+
],
|
|
615
|
+
}),
|
|
616
|
+
expect.objectContaining({ label: "Move layer (new keyframe)", keyframeAction: "add" }),
|
|
617
|
+
);
|
|
618
|
+
});
|
|
619
|
+
|
|
585
620
|
it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
|
|
586
621
|
const { commitMutation, handled } = await dragMotionPath(50);
|
|
587
622
|
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
computeCurrentPercentage,
|
|
24
24
|
findExistingPositionWrite,
|
|
25
25
|
findRotationSetAnimation,
|
|
26
|
+
stepListBlock,
|
|
26
27
|
} from "./gsapDragCommit";
|
|
27
28
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
28
29
|
import { isGestureTransactionCommit } from "./gestureTransaction";
|
|
@@ -218,7 +219,12 @@ async function planDrag(
|
|
|
218
219
|
if (!posAnim) {
|
|
219
220
|
return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
|
|
220
221
|
}
|
|
221
|
-
if (route === "whole-path")
|
|
222
|
+
if (route === "whole-path") {
|
|
223
|
+
const step = stepListBlock(posAnim);
|
|
224
|
+
return step
|
|
225
|
+
? { status: "blocked", reason: "keyframes-uneditable", detail: step }
|
|
226
|
+
: { status: "persisted" };
|
|
227
|
+
}
|
|
222
228
|
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
223
229
|
return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe);
|
|
224
230
|
}
|