@hyperframes/studio 0.8.133 → 0.8.135
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-Cjcwveg0.js → hyperframes-player-DFv9heE6.js} +1 -1
- package/dist/assets/{index-CwgWmfgx.js → index-Buy06mZy.js} +1 -1
- package/dist/assets/{index-ME-Z5jh6.js → index-CTp4loy9.js} +1 -1
- package/dist/assets/{index-Dj21zXWA.js → index-N_4Ij4X6.js} +184 -183
- package/dist/assets/{index-D11atkBt.css → index-syMK0_9C.css} +1 -1
- package/dist/{chunk-5VSXRGIX.js → chunk-NM7BXNZ7.js} +1 -1
- package/dist/chunk-NM7BXNZ7.js.map +1 -0
- package/dist/{chunk-FOEID72M.js → chunk-QRLKWENQ.js} +2 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.html +2 -2
- package/dist/index.js +911 -691
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +3 -2
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/editor/AnimationCard.test.tsx +60 -0
- package/src/components/editor/AnimationCard.tsx +16 -6
- package/src/components/editor/KeyframeEaseList.tsx +4 -1
- package/src/components/editor/LayersPanel.tsx +39 -47
- package/src/components/editor/domEditInlineText.ts +1 -1
- package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
- package/src/components/editor/gsapAnimationHelpers.ts +34 -0
- package/src/components/editor/layerKind.test.ts +38 -0
- package/src/components/editor/layerKind.ts +19 -0
- package/src/components/editor/manualEditsDom.ts +5 -1
- package/src/components/editor/plainTranslate.test.ts +21 -2
- package/src/components/editor/plainTranslate.ts +7 -0
- package/src/components/editor/useDomEditNudge.test.tsx +48 -27
- package/src/components/ui/cn.ts +2 -1
- package/src/contexts/DomEditContext.tsx +3 -1
- package/src/hooks/draggedGsapPosition.test.ts +34 -0
- package/src/hooks/draggedGsapPosition.ts +40 -15
- package/src/hooks/gsapDragCommit.ts +17 -10
- package/src/hooks/gsapDragPositionCommit.ts +8 -9
- package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
- package/src/hooks/gsapResizeIntercept.test.ts +72 -1
- package/src/hooks/gsapResizeIntercept.ts +84 -17
- package/src/hooks/gsapRuntimeBridge.ts +4 -1
- package/src/hooks/gsapRuntimePatch.ts +4 -0
- package/src/hooks/gsapShared.test.ts +11 -0
- package/src/hooks/gsapShared.ts +1 -1
- package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
- package/src/hooks/keyframeEase.realGsap.test.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +33 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +3 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +21 -0
- package/src/hooks/useDomGeometryCommits.ts +6 -1
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +3 -0
- package/src/hooks/useEnableKeyframes.ts +32 -37
- package/src/hooks/useGestureCommit.test.tsx +89 -0
- package/src/hooks/useGestureCommit.ts +26 -9
- package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +10 -2
- package/src/icons/SystemIcons.tsx +6 -0
- package/src/layersPanelExport.test.tsx +45 -4
- package/src/utils/elementGsap.ts +6 -5
- package/src/utils/gsapKeyframeEases.ts +2 -0
- package/src/utils/gsapLiveRetime.cache.test.ts +39 -0
- package/src/utils/gsapLiveRetime.realGsap.test.ts +39 -15
- package/src/utils/gsapLiveRetime.ts +39 -31
- package/src/utils/gsapSoftReload.test.ts +98 -14
- package/src/utils/gsapSoftReload.ts +14 -2
- package/src/utils/gsapUndoRestore.test.ts +43 -2
- package/src/utils/gsapUndoRestore.ts +4 -2
- package/src/utils/softReloadTargets.ts +56 -0
- package/src/utils/studioPendingEdits.test.ts +123 -0
- package/src/utils/studioPendingEdits.ts +39 -3
- package/dist/chunk-5VSXRGIX.js.map +0 -1
- /package/dist/{chunk-FOEID72M.js.map → chunk-QRLKWENQ.js.map} +0 -0
|
@@ -5,14 +5,18 @@
|
|
|
5
5
|
* frame can't jump. Split from gsapRuntimeBridge, which owns the shared
|
|
6
6
|
* group-tween resolution used by the drag/resize/rotate intercepts.
|
|
7
7
|
*/
|
|
8
|
-
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
8
|
+
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
9
9
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
10
|
import { clearStudioBoxSize } from "../components/editor/manualEdits";
|
|
11
11
|
import {
|
|
12
12
|
STUDIO_ORIGINAL_BOX_HEIGHT_ATTR,
|
|
13
13
|
STUDIO_ORIGINAL_BOX_WIDTH_ATTR,
|
|
14
14
|
} from "../components/editor/manualEditsTypes";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
setElementGsapPosition,
|
|
17
|
+
setElementGsapScale,
|
|
18
|
+
setElementGsapSize,
|
|
19
|
+
} from "../utils/elementGsap";
|
|
16
20
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
17
21
|
import { readGsapProperty } from "./gsapRuntimeReaders";
|
|
18
22
|
import {
|
|
@@ -23,7 +27,11 @@ import {
|
|
|
23
27
|
findSizeSetAnimation,
|
|
24
28
|
} from "./gsapDragCommit";
|
|
25
29
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
26
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
computeDraggedGsapPosition,
|
|
32
|
+
restoreDragOffset,
|
|
33
|
+
type DragStamp,
|
|
34
|
+
} from "./draggedGsapPosition";
|
|
27
35
|
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
|
|
28
36
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
29
37
|
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
@@ -39,9 +47,11 @@ import {
|
|
|
39
47
|
} from "./gsapEditOutcome";
|
|
40
48
|
import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
|
|
41
49
|
import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight";
|
|
50
|
+
import { singleKeyTweenMutation } from "./useEnableKeyframes";
|
|
42
51
|
|
|
43
52
|
const SIZE_PROPS = new Set(["width", "height"]);
|
|
44
53
|
const POSITION_XY = new Set(["x", "y"]);
|
|
54
|
+
const MOVED_OR_SIZED = new Set(["x", "y", "xPercent", "yPercent", "width", "height"]);
|
|
45
55
|
|
|
46
56
|
/**
|
|
47
57
|
* The element's box before the resize draft ran, in CSS pixels.
|
|
@@ -88,6 +98,7 @@ export async function commitSizeAtPlayhead(
|
|
|
88
98
|
selection.element,
|
|
89
99
|
dragOffset,
|
|
90
100
|
readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 },
|
|
101
|
+
callbacks.stamp,
|
|
91
102
|
)
|
|
92
103
|
: null;
|
|
93
104
|
const written = await commitValueAtPlayhead(
|
|
@@ -102,12 +113,43 @@ export async function commitSizeAtPlayhead(
|
|
|
102
113
|
...preGestureBoxSize(selection.element),
|
|
103
114
|
...(anchor && { x: anchor.baseGsapX, y: anchor.baseGsapY }),
|
|
104
115
|
},
|
|
105
|
-
...(anchor && { beforeReload: () => restoreDragOffset(selection.element) }),
|
|
116
|
+
...(anchor && { beforeReload: () => restoreDragOffset(selection.element, callbacks.stamp) }),
|
|
106
117
|
},
|
|
107
118
|
);
|
|
108
119
|
return written.status === "persisted" && anchor ? { ...written, ownsDragOffset: true } : written;
|
|
109
120
|
}
|
|
110
121
|
|
|
122
|
+
/** A keyed size is GSAP's at every time; the gesture's draft, re-applied after each seek, would pin it there. */
|
|
123
|
+
function handOverDraftSize(
|
|
124
|
+
selection: DomEditSelection,
|
|
125
|
+
written: GsapEditOutcome,
|
|
126
|
+
size: Record<string, number>,
|
|
127
|
+
draw: <T>(run: () => T) => T,
|
|
128
|
+
): GsapEditOutcome {
|
|
129
|
+
const { width, height } = size;
|
|
130
|
+
if (written.status !== "persisted" || width == null || height == null) return written;
|
|
131
|
+
draw(() => {
|
|
132
|
+
clearStudioBoxSize(selection.element);
|
|
133
|
+
setElementGsapSize(selection.element, width, height);
|
|
134
|
+
});
|
|
135
|
+
return written;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Under auto-record, a keyframed element's first resize is a size key at the playhead, held at all times. */
|
|
139
|
+
function firstSizeKey(
|
|
140
|
+
selection: DomEditSelection,
|
|
141
|
+
size: { width: number; height: number },
|
|
142
|
+
resizeGroup: PropertyGroupName,
|
|
143
|
+
animations: GsapAnimation[],
|
|
144
|
+
): Record<string, unknown> | null {
|
|
145
|
+
const { autoKeyframeEnabled, currentTime } = usePlayerStore.getState();
|
|
146
|
+
const keyframed = animations.some(
|
|
147
|
+
(a) => a.keyframes && animationWritesAnyProperty(a, MOVED_OR_SIZED),
|
|
148
|
+
);
|
|
149
|
+
if (resizeGroup !== "size" || !autoKeyframeEnabled || !keyframed) return null;
|
|
150
|
+
return singleKeyTweenMutation(selection, size, currentTime);
|
|
151
|
+
}
|
|
152
|
+
|
|
111
153
|
/**
|
|
112
154
|
* Whether this tween already states scale as `scaleX`/`scaleY`.
|
|
113
155
|
*
|
|
@@ -136,6 +178,7 @@ export async function tryGsapResizeIntercept(
|
|
|
136
178
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
137
179
|
dragOffset?: { x: number; y: number },
|
|
138
180
|
draw: <T>(run: () => T) => T = (run) => run(),
|
|
181
|
+
stamp?: DragStamp,
|
|
139
182
|
): Promise<GsapEditOutcome> {
|
|
140
183
|
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
141
184
|
const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
|
|
@@ -170,10 +213,16 @@ export async function tryGsapResizeIntercept(
|
|
|
170
213
|
size,
|
|
171
214
|
});
|
|
172
215
|
if (!anim || isInstantHold(anim)) {
|
|
216
|
+
const sized = { width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height) };
|
|
173
217
|
const scriptWritesSize = allKnownAnimations.some((a) =>
|
|
174
218
|
animationWritesAnyProperty(a, SIZE_PROPS),
|
|
175
219
|
);
|
|
176
|
-
if (!scriptWritesSize)
|
|
220
|
+
if (!scriptWritesSize) {
|
|
221
|
+
const firstKey = firstSizeKey(selection, sized, resizeGroup, workingAnimations);
|
|
222
|
+
if (!firstKey) return { status: "element-size" };
|
|
223
|
+
await commitMutation(selection, firstKey, { label: "Resize", softReload: true });
|
|
224
|
+
return handOverDraftSize(selection, { status: "persisted" }, sized, draw);
|
|
225
|
+
}
|
|
177
226
|
const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
|
|
178
227
|
if (!sel) return { status: "blocked", reason: "no-selector" };
|
|
179
228
|
// A scale hold is not a size hold.
|
|
@@ -191,8 +240,7 @@ export async function tryGsapResizeIntercept(
|
|
|
191
240
|
? (anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element))
|
|
192
241
|
: findSizeSetAnimation(workingAnimations, sel, selection.element);
|
|
193
242
|
|
|
194
|
-
// Keyframe the size
|
|
195
|
-
// rotate do; a fade or a slide on the element gets a plain size.
|
|
243
|
+
// Keyframe the size when a real tween already animates it; a static size set stays a set.
|
|
196
244
|
if (resizeGroup === "size") {
|
|
197
245
|
const animatedTween = pickClosestToPlayhead(
|
|
198
246
|
workingAnimations.filter(
|
|
@@ -204,11 +252,19 @@ export async function tryGsapResizeIntercept(
|
|
|
204
252
|
);
|
|
205
253
|
if (animatedTween) {
|
|
206
254
|
logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
255
|
+
const written = await commitSizeAtPlayhead(
|
|
256
|
+
selection,
|
|
257
|
+
animatedTween,
|
|
258
|
+
sized,
|
|
259
|
+
iframe,
|
|
260
|
+
dragOffset,
|
|
261
|
+
{
|
|
262
|
+
commitMutation,
|
|
263
|
+
fetchAnimations: fetchFallbackAnimations,
|
|
264
|
+
stamp,
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
return handOverDraftSize(selection, written, sized, draw);
|
|
212
268
|
}
|
|
213
269
|
}
|
|
214
270
|
|
|
@@ -333,8 +389,7 @@ export async function tryGsapResizeIntercept(
|
|
|
333
389
|
// it lands back on the drop point. The compensation only applies to a STATIC
|
|
334
390
|
// position (a `tl.set` hold or none) — a keyframed position path has no
|
|
335
391
|
// single anchor to preserve, so it keeps the plain center-scale behavior.
|
|
336
|
-
// The size route
|
|
337
|
-
// it needs none of this.
|
|
392
|
+
// The size route hands its draft to GSAP itself (handOverDraftSize).
|
|
338
393
|
// ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
|
|
339
394
|
// approximate rather than corner-exact.
|
|
340
395
|
// fallow-ignore-next-line complexity
|
|
@@ -363,6 +418,7 @@ export async function tryGsapResizeIntercept(
|
|
|
363
418
|
selection.element,
|
|
364
419
|
{ x: 0, y: 0 },
|
|
365
420
|
gsapPos,
|
|
421
|
+
stamp,
|
|
366
422
|
);
|
|
367
423
|
const base = { x: baseGsapX, y: baseGsapY };
|
|
368
424
|
setElementGsapPosition(draftEl, base.x, base.y);
|
|
@@ -424,6 +480,7 @@ export async function tryGsapResizeIntercept(
|
|
|
424
480
|
await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, {
|
|
425
481
|
commitMutation,
|
|
426
482
|
fetchAnimations: fetchFallbackAnimations,
|
|
483
|
+
stamp,
|
|
427
484
|
}),
|
|
428
485
|
);
|
|
429
486
|
return true;
|
|
@@ -432,6 +489,7 @@ export async function tryGsapResizeIntercept(
|
|
|
432
489
|
await commitStaticGsapPosition(selection, delta, base, selector, existingSet, {
|
|
433
490
|
commitMutation,
|
|
434
491
|
fetchAnimations: fetchFallbackAnimations,
|
|
492
|
+
stamp,
|
|
435
493
|
});
|
|
436
494
|
return true;
|
|
437
495
|
};
|
|
@@ -453,9 +511,18 @@ export async function tryGsapResizeIntercept(
|
|
|
453
511
|
return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
|
|
454
512
|
}
|
|
455
513
|
|
|
456
|
-
const callbacks = { commitMutation, fetchAnimations: fetchFallbackAnimations };
|
|
457
|
-
if (resizeGroup === "size")
|
|
458
|
-
|
|
514
|
+
const callbacks = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp };
|
|
515
|
+
if (resizeGroup === "size") {
|
|
516
|
+
const written = await commitSizeAtPlayhead(
|
|
517
|
+
selection,
|
|
518
|
+
anim,
|
|
519
|
+
resizeProps,
|
|
520
|
+
iframe,
|
|
521
|
+
dragOffset,
|
|
522
|
+
callbacks,
|
|
523
|
+
);
|
|
524
|
+
return handOverDraftSize(selection, written, resizeProps, draw);
|
|
525
|
+
}
|
|
459
526
|
const written = await commitValueAtPlayhead(selection, anim, resizeProps, iframe, callbacks, {
|
|
460
527
|
label: "Resize",
|
|
461
528
|
backfill: resizeBackfill,
|
|
@@ -31,6 +31,7 @@ import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
|
|
|
31
31
|
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
32
32
|
import { roundTo3 } from "../utils/rounding";
|
|
33
33
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
34
|
+
import type { DragStamp } from "./draggedGsapPosition";
|
|
34
35
|
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
35
36
|
import {
|
|
36
37
|
findGsapPositionAnimation,
|
|
@@ -243,6 +244,7 @@ export async function tryGsapDragIntercept(
|
|
|
243
244
|
preflightOnly?: boolean;
|
|
244
245
|
preflightPassed?: boolean;
|
|
245
246
|
group?: boolean;
|
|
247
|
+
stamp?: DragStamp;
|
|
246
248
|
},
|
|
247
249
|
): Promise<GsapEditOutcome> {
|
|
248
250
|
if (!options?.preflightPassed) {
|
|
@@ -323,6 +325,7 @@ export async function tryGsapDragIntercept(
|
|
|
323
325
|
await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
|
|
324
326
|
commitMutation,
|
|
325
327
|
fetchAnimations: fetchFallbackAnimations,
|
|
328
|
+
stamp: options?.stamp,
|
|
326
329
|
});
|
|
327
330
|
return { status: "persisted" };
|
|
328
331
|
}
|
|
@@ -352,7 +355,7 @@ export async function tryGsapDragIntercept(
|
|
|
352
355
|
}
|
|
353
356
|
}
|
|
354
357
|
|
|
355
|
-
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
|
|
358
|
+
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp: options?.stamp };
|
|
356
359
|
if (route === "whole-path") {
|
|
357
360
|
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
|
358
361
|
} else {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type RuntimeTween,
|
|
20
20
|
type RuntimeTimeline,
|
|
21
21
|
} from "./gsapRuntimeKeyframes";
|
|
22
|
+
import { recordLiveSet } from "../utils/softReloadTargets";
|
|
22
23
|
|
|
23
24
|
/** Value-only channels a `tl.set(...)` patch may touch. */
|
|
24
25
|
export interface SetPatchProps {
|
|
@@ -34,6 +35,8 @@ export interface SetPatchProps {
|
|
|
34
35
|
scaleY?: number;
|
|
35
36
|
scale?: number;
|
|
36
37
|
opacity?: number;
|
|
38
|
+
width?: number;
|
|
39
|
+
height?: number;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/** A single keyframe step's numeric channels (the GSAP array-keyframe form). */
|
|
@@ -101,6 +104,7 @@ function applyGlobalSet(
|
|
|
101
104
|
const numeric = finiteNumericProps(props);
|
|
102
105
|
if (!gsapLib?.set || !el || !numeric) return false;
|
|
103
106
|
gsapLib.set(el, numeric);
|
|
107
|
+
recordLiveSet(el, numeric);
|
|
104
108
|
return true;
|
|
105
109
|
} catch {
|
|
106
110
|
return false;
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
idFromSelector,
|
|
5
5
|
idSelector,
|
|
6
6
|
isInstantHold,
|
|
7
|
+
keyframeEases,
|
|
7
8
|
parsePercentageKeyframes,
|
|
8
9
|
resolveClipTimingBasis,
|
|
9
10
|
playsNear,
|
|
@@ -11,6 +12,16 @@ import {
|
|
|
11
12
|
toClipPercentage,
|
|
12
13
|
} from "./gsapShared";
|
|
13
14
|
|
|
15
|
+
describe("keyframeEases", () => {
|
|
16
|
+
it("carries the keyframes' own ease into the tween ease, which GSAP plays the same", () => {
|
|
17
|
+
const anim = {
|
|
18
|
+
ease: "back.out",
|
|
19
|
+
keyframes: { keyframes: [], ease: "sine.inOut", easeEach: "power2.out" },
|
|
20
|
+
} as unknown as GsapAnimation;
|
|
21
|
+
expect(keyframeEases(anim)).toEqual({ ease: "sine.inOut", easeEach: "power2.out" });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
14
25
|
describe("isInstantHold", () => {
|
|
15
26
|
const animation = (method: GsapAnimation["method"], duration?: number) =>
|
|
16
27
|
({ method, duration }) as unknown as GsapAnimation;
|
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -41,7 +41,7 @@ export const PROPERTY_DEFAULTS: Record<string, number> = {
|
|
|
41
41
|
* since GSAP eases each percentage segment power1.inOut whatever the tween-level ease says. */
|
|
42
42
|
export function keyframeEases(anim: GsapAnimation): { ease?: string; easeEach?: string } {
|
|
43
43
|
if (!anim.keyframes) return { easeEach: anim.ease };
|
|
44
|
-
return { ease: anim
|
|
44
|
+
return { ease: runEaseOf(anim), easeEach: anim.keyframes.easeEach };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { trimTrailingKeyframeSpans } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
2
|
+
import { gsap } from "gsap";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
|
|
6
|
+
|
|
7
|
+
// What Studio saved for a plain element keyed at 1, 2 and 3 s, and for its size keyed at 3 s then 2 s.
|
|
8
|
+
const saved = {
|
|
9
|
+
position:
|
|
10
|
+
`tl.set("#target", { x: 299.893, data: "hf-hold" }, 0);\n` +
|
|
11
|
+
`tl.to("#target", { keyframes: { "0%": { x: 299.893 }, "33.333%": { x: 297.086 }, "66.667%": { x: 47.754 } }, duration: 3 }, 1);`,
|
|
12
|
+
size:
|
|
13
|
+
`tl.set("#target", { width: 440, data: "hf-hold" }, 0);\n` +
|
|
14
|
+
`tl.to("#target", { keyframes: { "0%": { width: 440 }, "50%": { width: 340, ease: "none" } }, duration: 2 }, 2);`,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function valueAt(script: string, prop: "x" | "width", seeks: number[]) {
|
|
18
|
+
const target: Record<string, number> = { x: 0, width: 240 };
|
|
19
|
+
const run = new Function(
|
|
20
|
+
"gsap",
|
|
21
|
+
"target",
|
|
22
|
+
`${script.replaceAll('"#target"', "target")}\nreturn tl;`,
|
|
23
|
+
);
|
|
24
|
+
const tl = run(gsap, target) as gsap.core.Timeline;
|
|
25
|
+
for (const time of seeks) tl.seek(time);
|
|
26
|
+
return target[prop];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe.each([
|
|
30
|
+
["position", "x", 47.754],
|
|
31
|
+
["size", "width", 340],
|
|
32
|
+
] as const)("a %s tween Studio keyed short of its end", (name, prop, atThree) => {
|
|
33
|
+
it("plays and seeks to the same value once its tail is trimmed", () => {
|
|
34
|
+
const script = trimTrailingKeyframeSpans(timeline, timeline + saved[name]);
|
|
35
|
+
expect(valueAt(script, prop, [3])).toBeCloseTo(atThree, 3);
|
|
36
|
+
expect(valueAt(script, prop, [1, 2, 3])).toBeCloseTo(atThree, 3);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("is what GSAP 3.15 gets wrong on a direct seek without the trim", () => {
|
|
40
|
+
const script = timeline + saved[name];
|
|
41
|
+
expect(valueAt(script, prop, [1, 2, 3])).toBeCloseTo(atThree, 3);
|
|
42
|
+
expect(valueAt(script, prop, [3])).not.toBeCloseTo(atThree, 0);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Real gsap: the ease the parser reports for keyframes must be the one GSAP plays.
|
|
2
|
+
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
|
3
|
+
import { gsap } from "gsap";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
const STEPS = { "0%": { x: 0 }, "100%": { x: 10 } };
|
|
7
|
+
|
|
8
|
+
function xAt(vars: gsap.TweenVars, time = 0.3): number {
|
|
9
|
+
const target = { x: 0 };
|
|
10
|
+
gsap
|
|
11
|
+
.timeline({ paused: true })
|
|
12
|
+
.to(target, { duration: 1, ...vars }, 0)
|
|
13
|
+
.seek(time);
|
|
14
|
+
return target.x;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const parsedEaseEach = (vars: string) =>
|
|
18
|
+
parseGsapScriptAcorn(`const tl = gsap.timeline({ paused: true });
|
|
19
|
+
tl.to("#a", { duration: 1, ${vars} }, 0);`).animations[0]!.keyframes?.easeEach;
|
|
20
|
+
|
|
21
|
+
describe("the keyframes ease the parser reports", () => {
|
|
22
|
+
it("is not a tween-level easeEach, which GSAP ignores", () => {
|
|
23
|
+
expect(xAt({ keyframes: STEPS, easeEach: "power4.in" })).toBe(xAt({ keyframes: STEPS }));
|
|
24
|
+
expect(
|
|
25
|
+
parsedEaseEach('keyframes: { "0%": { x: 0 }, "100%": { x: 10 } }, easeEach: "power4.in"'),
|
|
26
|
+
).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("is an easeEach inside the keyframes, which GSAP plays", () => {
|
|
30
|
+
expect(xAt({ keyframes: { ...STEPS, easeEach: "power4.in" } })).not.toBe(
|
|
31
|
+
xAt({ keyframes: STEPS }),
|
|
32
|
+
);
|
|
33
|
+
expect(
|
|
34
|
+
parsedEaseEach('keyframes: { "0%": { x: 0 }, "100%": { x: 10 }, easeEach: "power4.in" }'),
|
|
35
|
+
).toBe("power4.in");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -10,6 +10,7 @@ import { mountReactHarness, withInlineLayoutBox } from "./domSelectionTestHarnes
|
|
|
10
10
|
import { writeSizeWithCrop } from "../components/editor/cropResize";
|
|
11
11
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
12
12
|
import { trackKeyframeCommit } from "../utils/keyframeUsage";
|
|
13
|
+
import { xAtTime } from "./gsapPlaybackTestHarness";
|
|
13
14
|
import type { CommitMutationOptions, CommitMutation } from "./gsapScriptCommitTypes";
|
|
14
15
|
|
|
15
16
|
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
@@ -205,6 +206,38 @@ function renderCommitHook(
|
|
|
205
206
|
// canvas nudge exercises (not the raw drag intercept) — with auto-keyframe
|
|
206
207
|
// off, it must shift the whole tween instead of adding/updating a keyframe
|
|
207
208
|
// at the playhead.
|
|
209
|
+
describe("useAnimatedPropertyCommit — extending a keyframed tween to the playhead", () => {
|
|
210
|
+
it("keeps the tween's eases and each keyframe's own ease", async () => {
|
|
211
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 3, activeKeyframePct: null });
|
|
212
|
+
const eased = {
|
|
213
|
+
...keyframedAnim,
|
|
214
|
+
ease: "power2.inOut",
|
|
215
|
+
keyframes: {
|
|
216
|
+
easeEach: "power2.out",
|
|
217
|
+
keyframes: [
|
|
218
|
+
{ percentage: 0, properties: { x: 0, y: 0 }, ease: "expo.in" },
|
|
219
|
+
{ percentage: 100, properties: { x: 100, y: 0 } },
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
} as unknown as GsapAnimation;
|
|
223
|
+
const mutations: Array<Record<string, unknown>> = [];
|
|
224
|
+
let commit!: Commit;
|
|
225
|
+
const root = renderHookWith(
|
|
226
|
+
[eased],
|
|
227
|
+
(mutation) => mutations.push(mutation),
|
|
228
|
+
(ready) => (commit = ready),
|
|
229
|
+
);
|
|
230
|
+
await act(async () => commit(selection, { x: 50 }));
|
|
231
|
+
act(() => root.unmount());
|
|
232
|
+
|
|
233
|
+
const replace = mutations.find((m) => m.type === "replace-with-keyframes")!;
|
|
234
|
+
expect(replace).toMatchObject({ ease: "power2.inOut", easeEach: "power2.out" });
|
|
235
|
+
expect((replace.keyframes as Array<{ ease?: string }>)[0]!.ease).toBe("expo.in");
|
|
236
|
+
// The old end keyframe still plays at 2 s once the tween runs to the playhead at 3 s.
|
|
237
|
+
expect(xAtTime(replace as unknown as Parameters<typeof xAtTime>[0], 2)).toBeCloseTo(100, 0);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
208
241
|
describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", () => {
|
|
209
242
|
async function runCommitWithAutoKeyframe(enabled: boolean) {
|
|
210
243
|
usePlayerStore.setState({ autoKeyframeEnabled: enabled, currentTime: 0 });
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
selectorFromSelection,
|
|
19
19
|
computeElementPercentage,
|
|
20
20
|
isInstantHold,
|
|
21
|
+
keyframeEases,
|
|
21
22
|
writeTargetSelector,
|
|
22
23
|
tweenTargetsElement,
|
|
23
24
|
} from "./gsapShared";
|
|
@@ -375,7 +376,7 @@ async function commitKeyframeProps(
|
|
|
375
376
|
for (const k of Object.keys(properties)) {
|
|
376
377
|
if (!(k in p) && backfillDefaults[k] != null) p[k] = backfillDefaults[k];
|
|
377
378
|
}
|
|
378
|
-
return { percentage: newPct, properties: p };
|
|
379
|
+
return { percentage: newPct, properties: p, ...(kf.ease ? { ease: kf.ease } : {}) };
|
|
379
380
|
});
|
|
380
381
|
remapped.push({ percentage: toNewPct(ct), properties });
|
|
381
382
|
remapped.sort((a, b) => a.percentage - b.percentage);
|
|
@@ -388,6 +389,7 @@ async function commitKeyframeProps(
|
|
|
388
389
|
position: roundTo3(newStart),
|
|
389
390
|
duration: roundTo3(newDuration),
|
|
390
391
|
keyframes: remapped,
|
|
392
|
+
...keyframeEases(anim),
|
|
391
393
|
},
|
|
392
394
|
{ label: `Edit ${primaryProp} (extended keyframe)`, keyframeAction: "add", softReload: true },
|
|
393
395
|
);
|
|
@@ -376,6 +376,27 @@ describe("Reset after a plain move puts the author's translate back", () => {
|
|
|
376
376
|
});
|
|
377
377
|
});
|
|
378
378
|
|
|
379
|
+
describe("useDomGeometryCommits undo steps", () => {
|
|
380
|
+
it("never merges a resize with the next size edit of the same element", async () => {
|
|
381
|
+
const element = document.createElement("div");
|
|
382
|
+
element.id = "box";
|
|
383
|
+
document.body.append(element);
|
|
384
|
+
const selection = { id: "box", selector: "#box", element } as unknown as DomEditSelection;
|
|
385
|
+
const commitPositionPatchToHtml = vi
|
|
386
|
+
.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
|
|
387
|
+
.mockResolvedValue(undefined);
|
|
388
|
+
const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
|
|
389
|
+
|
|
390
|
+
await commits().handleDomBoxSizeCommit(selection, { width: 418, height: 200 });
|
|
391
|
+
await commits().handleDomBoxSizeCommit(selection, { width: 280, height: 200 });
|
|
392
|
+
|
|
393
|
+
const [resize, field] = commitPositionPatchToHtml.mock.calls.map(([, , options]) => options);
|
|
394
|
+
expect(resize!.coalesceKey).not.toBe(field!.coalesceKey);
|
|
395
|
+
unmount();
|
|
396
|
+
element.remove();
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
379
400
|
describe("useDomGeometryCommits resize of a cropped element", () => {
|
|
380
401
|
it("saves the crop scaled per axis with the box in the resize's own commit", async () => {
|
|
381
402
|
const element = withInlineLayoutBox(document.createElement("div"));
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
} from "../components/editor/manualEditsDomPatches";
|
|
27
27
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
28
28
|
|
|
29
|
+
let boxSizeCommitCounter = 0;
|
|
30
|
+
|
|
29
31
|
// ── Hook ──
|
|
30
32
|
|
|
31
33
|
export interface UseDomGeometryCommitsParams {
|
|
@@ -90,7 +92,10 @@ export function useDomGeometryCommits({
|
|
|
90
92
|
label: "Resize layer box",
|
|
91
93
|
...(undoKey
|
|
92
94
|
? { coalesceKey: undoKey, coalesceMs: Number.POSITIVE_INFINITY, deferRender: true }
|
|
93
|
-
: {
|
|
95
|
+
: {
|
|
96
|
+
coalesceKey: `box-size:${++boxSizeCommitCounter}`,
|
|
97
|
+
coalesceMs: Number.POSITIVE_INFINITY,
|
|
98
|
+
}),
|
|
94
99
|
}).catch((error) => {
|
|
95
100
|
restoreStudioBoxSize(element, beforeSize);
|
|
96
101
|
if (offset) restoreStudioPathOffset(element, beforeOffset);
|
|
@@ -7,6 +7,7 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
|
7
7
|
import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
|
|
8
8
|
import {
|
|
9
9
|
beginStudioPendingEdit,
|
|
10
|
+
isStudioEditSaving,
|
|
10
11
|
setStudioPendingEditClaimClock,
|
|
11
12
|
trackStudioPendingEdit,
|
|
12
13
|
} from "../utils/studioPendingEdits";
|
|
@@ -97,6 +98,25 @@ describe("useEditHistoryActions", () => {
|
|
|
97
98
|
});
|
|
98
99
|
});
|
|
99
100
|
|
|
101
|
+
it("counts a shown step as saving until the server takes it, so a preview reload waits for its write", async () => {
|
|
102
|
+
const { deps, actions } = mount(
|
|
103
|
+
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
|
|
104
|
+
PREDICTED,
|
|
105
|
+
);
|
|
106
|
+
let step!: () => void;
|
|
107
|
+
const stepped = new Promise<void>((resolve) => (step = resolve));
|
|
108
|
+
const undo = deps.editHistory.undo.getMockImplementation()!;
|
|
109
|
+
deps.editHistory.undo.mockImplementation(async (cb) => (await stepped, undo(cb)));
|
|
110
|
+
|
|
111
|
+
const undone = actions.undo();
|
|
112
|
+
expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files);
|
|
113
|
+
expect(isStudioEditSaving()).toBe(true);
|
|
114
|
+
step();
|
|
115
|
+
await act(() => undone);
|
|
116
|
+
await Promise.resolve();
|
|
117
|
+
expect(isStudioEditSaving()).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
100
120
|
it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => {
|
|
101
121
|
const { deps, actions } = mount(
|
|
102
122
|
{
|
|
@@ -4,6 +4,7 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
|
4
4
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
5
5
|
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
6
6
|
import {
|
|
7
|
+
beginStudioShownRestore,
|
|
7
8
|
hasStudioPendingEdits,
|
|
8
9
|
paintBackNewestStudioPendingEdit,
|
|
9
10
|
} from "../utils/studioPendingEdits";
|
|
@@ -82,6 +83,7 @@ export function useEditHistoryActions({
|
|
|
82
83
|
const pendingEditShown = direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
|
|
83
84
|
const predicted = pendingEditShown ? null : (editHistory.predict?.(direction) ?? null);
|
|
84
85
|
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
|
|
86
|
+
const shownLanded = predictedShown ? beginStudioShownRestore() : null;
|
|
85
87
|
const putBack = predictedShown ?? pendingEditShown?.showAgain;
|
|
86
88
|
const claimedAfter =
|
|
87
89
|
pendingEditShown?.claimsAtBegin ??
|
|
@@ -107,6 +109,7 @@ export function useEditHistoryActions({
|
|
|
107
109
|
: stepped && editClaimed;
|
|
108
110
|
} finally {
|
|
109
111
|
if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
|
|
112
|
+
shownLanded?.();
|
|
110
113
|
}
|
|
111
114
|
if (!result.ok && result.reason === "content-mismatch") {
|
|
112
115
|
showToast(
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { POSITION_PROPS } from "./gsapRuntimeReaders";
|
|
32
32
|
import { findParsedTween, withLiveTiming } from "./gsapParsedTween";
|
|
33
33
|
import { roundTo3 } from "../utils/rounding";
|
|
34
|
+
import { readTranslatePxLeavingPercent } from "../components/editor/plainTranslate";
|
|
34
35
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
35
36
|
import {
|
|
36
37
|
buildExtendedKeyframes,
|
|
@@ -180,6 +181,32 @@ export function resolveNewTweenRange(
|
|
|
180
181
|
return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) };
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
export function singleKeyTweenMutation(
|
|
185
|
+
sel: DomEditSelection,
|
|
186
|
+
properties: Record<string, number>,
|
|
187
|
+
currentTime: number,
|
|
188
|
+
): Record<string, unknown> | null {
|
|
189
|
+
// A brand-new tween: author it against the one element the user selected.
|
|
190
|
+
// The bare class selectorFromSelection hands back for an id-less element
|
|
191
|
+
// animates every sibling sharing the class (see writeTargetSelector).
|
|
192
|
+
const targetSelector = writeTargetSelector(sel);
|
|
193
|
+
if (!targetSelector) return null;
|
|
194
|
+
const { start, duration } = resolveNewTweenRange(
|
|
195
|
+
sel.dataAttributes?.start,
|
|
196
|
+
sel.dataAttributes?.duration,
|
|
197
|
+
currentTime,
|
|
198
|
+
);
|
|
199
|
+
// One keyframe at the playhead, a single diamond capturing the current value;
|
|
200
|
+
// motion comes from the keyframes the user adds or drags later.
|
|
201
|
+
return {
|
|
202
|
+
type: "add-with-keyframes",
|
|
203
|
+
targetSelector,
|
|
204
|
+
position: roundTo3(start),
|
|
205
|
+
duration: roundTo3(duration),
|
|
206
|
+
keyframes: [{ percentage: 0, properties }],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
183
210
|
// Authoritative parse of the current source for `sel`. Returns `null` when the
|
|
184
211
|
// fetch can't run (no projectId / request failed) so callers can distinguish
|
|
185
212
|
// "unavailable" from a genuine empty result (e.g. after a delete-all). An empty
|
|
@@ -514,43 +541,11 @@ export function useEnableKeyframes(
|
|
|
514
541
|
}
|
|
515
542
|
} else {
|
|
516
543
|
const position = readElementPosition(iframe, sel, null);
|
|
517
|
-
|
|
518
|
-
sel.
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
// A brand-new tween: author it against the one element the user selected.
|
|
523
|
-
// The bare class selectorFromSelection hands back for an id-less element
|
|
524
|
-
// animates every sibling sharing the class (see writeTargetSelector).
|
|
525
|
-
const selector = writeTargetSelector(sel);
|
|
526
|
-
|
|
527
|
-
if (!selector) {
|
|
528
|
-
session.handleGsapAddAnimation("to");
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
if (Object.keys(position).length === 0) {
|
|
533
|
-
position.x = 0;
|
|
534
|
-
position.y = 0;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// One keyframe at the playhead — a single diamond capturing the current
|
|
538
|
-
// value. Motion comes from the user adding/dragging more keyframes later;
|
|
539
|
-
// creating 0%+100% up front showed two diamonds for a single "add keyframe".
|
|
540
|
-
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
|
541
|
-
[{ percentage: 0, properties: { ...position } }];
|
|
542
|
-
|
|
543
|
-
if (session.commitMutation) {
|
|
544
|
-
await session.commitMutation(
|
|
545
|
-
{
|
|
546
|
-
type: "add-with-keyframes",
|
|
547
|
-
targetSelector: selector,
|
|
548
|
-
position: roundTo3(elStart),
|
|
549
|
-
duration: roundTo3(elDuration),
|
|
550
|
-
keyframes,
|
|
551
|
-
},
|
|
552
|
-
{ label: "Enable keyframes", softReload: true },
|
|
553
|
-
);
|
|
544
|
+
if (Object.keys(position).length === 0)
|
|
545
|
+
Object.assign(position, readTranslatePxLeavingPercent(sel.element));
|
|
546
|
+
const mutation = singleKeyTweenMutation(sel, position, t);
|
|
547
|
+
if (mutation && session.commitMutation) {
|
|
548
|
+
await session.commitMutation(mutation, { label: "Enable keyframes", softReload: true });
|
|
554
549
|
} else {
|
|
555
550
|
session.handleGsapAddAnimation("to");
|
|
556
551
|
}
|