@hyperframes/studio 0.8.127 → 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-uqzDaxVL.js → hyperframes-player-CKCx0CFA.js} +1 -1
- package/dist/assets/{index-DXSYlqmV.js → index-CF31Pq1E.js} +1 -1
- package/dist/assets/{index-COhbL1xq.js → index-ClLzyuwb.js} +171 -171
- package/dist/assets/{index-CLBNYlDY.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 +1193 -869
- 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/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/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 +8 -5
- package/src/hooks/gsapDragPositionCommit.ts +90 -17
- 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.ts +5 -6
- package/src/hooks/gsapPositionDetection.ts +5 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
- package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
- package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
- package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
- package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
- package/src/hooks/gsapShared.test.ts +48 -35
- package/src/hooks/gsapShared.ts +30 -32
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
- package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
- package/src/hooks/gsapValueAtPlayhead.ts +3 -4
- 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 +42 -7
- 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/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
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
* `toAbsoluteTime` + the element's clip start/duration. `scanAllRuntimeKeyframes`
|
|
9
9
|
* does that conversion itself when given a `clipById` map.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import type { ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
|
|
12
12
|
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
|
|
13
|
+
import { timeAtProgress } from "../utils/gsapKeyframeEases";
|
|
14
|
+
import { readMotionPathTween } from "./gsapRuntimeMotionPath";
|
|
13
15
|
import { roundTo3 } from "../utils/rounding";
|
|
16
|
+
import { matchesElement, tweensTargeting } from "./gsapRuntimeTweenIndex";
|
|
14
17
|
import { withParsedStart } from "./gsapParsedTween";
|
|
15
18
|
import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
|
|
16
19
|
import { gsapRendersTransform } from "../components/editor/gsapAnimatesProperty";
|
|
@@ -48,6 +51,7 @@ export type ReadTween = {
|
|
|
48
51
|
keyframes: Pct[];
|
|
49
52
|
easeEach?: string;
|
|
50
53
|
arcPath?: ArcPathConfig;
|
|
54
|
+
runEase?: string;
|
|
51
55
|
/** GSAP's start values when the first keyframe is after 0%. */
|
|
52
56
|
start?: Record<string, number>;
|
|
53
57
|
};
|
|
@@ -92,10 +96,6 @@ function timelinesOf(iframe: HTMLIFrameElement | null): Record<string, RuntimeTi
|
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
|
|
95
|
-
function isXY(p: unknown): p is { x: number; y: number } {
|
|
96
|
-
return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
|
|
97
|
-
}
|
|
98
|
-
|
|
99
99
|
/**
|
|
100
100
|
* A tween we must skip when reading keyframes: a zero-duration `set`/hold (incl.
|
|
101
101
|
* the studio pre-keyframe position hold, tagged `data: STUDIO_HOLD_MARKER`).
|
|
@@ -107,31 +107,6 @@ function isZeroDurationSet(duration: number): boolean {
|
|
|
107
107
|
return !(duration > 0);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
/** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
|
|
111
|
-
function coordsFromMotionPath(mp: unknown): {
|
|
112
|
-
coords: Array<{ x: number; y: number }>;
|
|
113
|
-
curviness: number;
|
|
114
|
-
autoRotate: boolean | number;
|
|
115
|
-
isCubic: boolean;
|
|
116
|
-
} | null {
|
|
117
|
-
if (!mp || typeof mp !== "object") return null;
|
|
118
|
-
const obj = mp as Record<string, unknown>;
|
|
119
|
-
const pathVal = Array.isArray(mp) ? mp : obj.path;
|
|
120
|
-
if (!Array.isArray(pathVal)) return null;
|
|
121
|
-
const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
|
|
122
|
-
if (coords.length < 2) return null;
|
|
123
|
-
const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
|
|
124
|
-
const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
|
|
125
|
-
return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Build an arcPath config from a live `vars.motionPath` value. */
|
|
129
|
-
export function arcPathFromMotionPathValue(mp: unknown): ArcPathConfig | undefined {
|
|
130
|
-
const parsed = coordsFromMotionPath(mp);
|
|
131
|
-
if (!parsed) return undefined;
|
|
132
|
-
return buildArcPath(parsed.coords, parsed.curviness, parsed.autoRotate, parsed.isCubic)?.arcPath;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
110
|
function flatTweenKeyframes(vars: Record<string, unknown>): Pct[] | null {
|
|
136
111
|
const properties: Record<string, number | string> = {};
|
|
137
112
|
for (const [k, v] of Object.entries(vars)) {
|
|
@@ -146,36 +121,22 @@ function flatTweenKeyframes(vars: Record<string, unknown>): Pct[] | null {
|
|
|
146
121
|
];
|
|
147
122
|
}
|
|
148
123
|
|
|
124
|
+
const stringOr = (value: unknown) => (typeof value === "string" ? value : undefined);
|
|
125
|
+
|
|
149
126
|
/** Tween-relative keyframes + optional arcPath for one live tween, or null. */
|
|
150
127
|
function readTween(vars: Record<string, unknown>): ReadTween | null {
|
|
128
|
+
const runEase = stringOr(vars.ease);
|
|
151
129
|
if (vars.keyframes && typeof vars.keyframes === "object") {
|
|
152
130
|
const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const mp = coordsFromMotionPath(vars.motionPath);
|
|
156
|
-
if (mp) {
|
|
157
|
-
const shape = buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
|
|
158
|
-
if (shape) {
|
|
159
|
-
const n = shape.waypoints.length;
|
|
160
|
-
const keyframes = shape.waypoints.map((wp, i) => ({
|
|
161
|
-
percentage: n > 1 ? Math.round((i / (n - 1)) * 100) : 0,
|
|
162
|
-
properties: { x: wp.x, y: wp.y },
|
|
163
|
-
}));
|
|
164
|
-
return { keyframes, arcPath: shape.arcPath };
|
|
165
|
-
}
|
|
131
|
+
const keyframesEase = stringOr((vars.keyframes as { ease?: unknown }).ease);
|
|
132
|
+
if (parsed) return { ...parsed, runEase: keyframesEase ?? runEase };
|
|
166
133
|
}
|
|
134
|
+
const path = readMotionPathTween(vars, runEase);
|
|
135
|
+
if (path) return path;
|
|
167
136
|
const flat = flatTweenKeyframes(vars);
|
|
168
137
|
return flat ? { keyframes: flat } : null;
|
|
169
138
|
}
|
|
170
139
|
|
|
171
|
-
function matchesElement(tween: RuntimeTween, el: Element): boolean {
|
|
172
|
-
if (!tween.targets) return false;
|
|
173
|
-
for (const t of tween.targets()) {
|
|
174
|
-
if (t === el || (el.id && (t as Element).id === el.id)) return true;
|
|
175
|
-
}
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
140
|
function tweenTiming(tween: RuntimeTween): { start: number; duration: number } {
|
|
180
141
|
const rawStart = typeof tween.startTime === "function" ? tween.startTime() : 0;
|
|
181
142
|
const rawDur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
@@ -438,10 +399,9 @@ export const GSAP_TRANSFORM_KEYS = new Set(
|
|
|
438
399
|
export function gsapWritesChannels(el: Element, channels: string[]): boolean {
|
|
439
400
|
const win = el.ownerDocument.defaultView as { __timelines?: Record<string, RuntimeTimeline> };
|
|
440
401
|
return Object.values(win?.__timelines ?? {}).some((tl) =>
|
|
441
|
-
(tl
|
|
402
|
+
tweensTargeting(tl, el).some(
|
|
442
403
|
(tween) =>
|
|
443
404
|
!!tween.vars &&
|
|
444
|
-
matchesElement(tween, el) &&
|
|
445
405
|
(channels.some((ch) => ch in tween.vars!) ||
|
|
446
406
|
keyframeVarsCarryChannel(tween.vars, channels)),
|
|
447
407
|
),
|
|
@@ -515,9 +475,8 @@ function hasNonHoldTween(
|
|
|
515
475
|
): boolean {
|
|
516
476
|
if (!timelines) return false;
|
|
517
477
|
for (const tlId of compositionId ? [compositionId] : Object.keys(timelines)) {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
|
|
478
|
+
for (const tween of tweensTargeting(timelines[tlId], targetEl)) {
|
|
479
|
+
if (!tween.vars) continue;
|
|
521
480
|
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
522
481
|
if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
|
|
523
482
|
if (channels && keyframeVarsCarryChannel(tween.vars, channels)) return true;
|
|
@@ -530,14 +489,15 @@ function hasNonHoldTween(
|
|
|
530
489
|
|
|
531
490
|
/** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */
|
|
532
491
|
function toClipRelative(
|
|
533
|
-
|
|
492
|
+
read: ReadTween,
|
|
534
493
|
tweenStart: number,
|
|
535
494
|
tweenDuration: number,
|
|
536
495
|
clip: { start: number; duration: number } | undefined,
|
|
537
496
|
): Pct[] {
|
|
497
|
+
const { keyframes, runEase } = read;
|
|
538
498
|
if (!clip || clip.duration <= 0) return keyframes;
|
|
539
499
|
return keyframes.map((kf) => {
|
|
540
|
-
const abs = toAbsoluteTime(tweenStart, tweenDuration, kf.percentage);
|
|
500
|
+
const abs = toAbsoluteTime(tweenStart, tweenDuration, timeAtProgress(runEase, kf.percentage));
|
|
541
501
|
return { ...kf, percentage: Math.round(((abs - clip.start) / clip.duration) * 100000) / 1000 };
|
|
542
502
|
});
|
|
543
503
|
}
|
|
@@ -549,7 +509,7 @@ function buildEntry(
|
|
|
549
509
|
clip: { start: number; duration: number } | undefined,
|
|
550
510
|
): RuntimeKeyframeEntry {
|
|
551
511
|
return {
|
|
552
|
-
keyframes: toClipRelative(read
|
|
512
|
+
keyframes: toClipRelative(read, start, duration, clip),
|
|
553
513
|
tweenStart: start,
|
|
554
514
|
tweenDuration: duration,
|
|
555
515
|
...(read.easeEach ? { easeEach: read.easeEach } : {}),
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
|
|
2
|
+
import { MOTION_PATH_RUN_EASE } from "../utils/gsapKeyframeEases";
|
|
3
|
+
import type { ReadTween } from "./gsapRuntimeKeyframes";
|
|
4
|
+
|
|
5
|
+
function isXY(p: unknown): p is { x: number; y: number } {
|
|
6
|
+
return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
|
|
10
|
+
function coordsFromMotionPath(mp: unknown): {
|
|
11
|
+
coords: Array<{ x: number; y: number }>;
|
|
12
|
+
curviness: number;
|
|
13
|
+
autoRotate: boolean | number;
|
|
14
|
+
isCubic: boolean;
|
|
15
|
+
} | null {
|
|
16
|
+
if (!mp || typeof mp !== "object") return null;
|
|
17
|
+
const obj = mp as Record<string, unknown>;
|
|
18
|
+
const pathVal = Array.isArray(mp) ? mp : obj.path;
|
|
19
|
+
if (!Array.isArray(pathVal)) return null;
|
|
20
|
+
const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
|
|
21
|
+
if (coords.length < 2) return null;
|
|
22
|
+
const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
|
|
23
|
+
const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
|
|
24
|
+
return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Build an arcPath config from a live `vars.motionPath` value. */
|
|
28
|
+
export function arcPathFromMotionPathValue(mp: unknown): ArcPathConfig | undefined {
|
|
29
|
+
const parsed = coordsFromMotionPath(mp);
|
|
30
|
+
if (!parsed) return undefined;
|
|
31
|
+
return buildArcPath(parsed.coords, parsed.curviness, parsed.autoRotate, parsed.isCubic)?.arcPath;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readMotionPathTween(
|
|
35
|
+
vars: Record<string, unknown>,
|
|
36
|
+
runEase?: string,
|
|
37
|
+
): ReadTween | null {
|
|
38
|
+
const mp = coordsFromMotionPath(vars.motionPath);
|
|
39
|
+
const shape = mp && buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
|
|
40
|
+
if (!shape) return null;
|
|
41
|
+
const n = shape.waypoints.length;
|
|
42
|
+
const keyframes = shape.waypoints.map((wp, i) => ({
|
|
43
|
+
percentage: n > 1 ? Math.round((i / (n - 1)) * 100) : 0,
|
|
44
|
+
properties: { x: wp.x, y: wp.y },
|
|
45
|
+
}));
|
|
46
|
+
return { keyframes, arcPath: shape.arcPath, runEase: runEase ?? MOTION_PATH_RUN_EASE };
|
|
47
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { RuntimeTimeline, RuntimeTween } from "./gsapRuntimeKeyframes";
|
|
2
|
+
|
|
3
|
+
export function matchesElement(tween: RuntimeTween, el: Element): boolean {
|
|
4
|
+
if (!tween.targets) return false;
|
|
5
|
+
for (const t of tween.targets()) {
|
|
6
|
+
if (t === el || (el.id && (t as Element).id === el.id)) return true;
|
|
7
|
+
}
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type TweenIndex = { byElement: Map<unknown, RuntimeTween[]>; byId: Map<string, RuntimeTween[]> };
|
|
12
|
+
let tweenIndexes: WeakMap<RuntimeTimeline, TweenIndex> | null = null;
|
|
13
|
+
|
|
14
|
+
/** Within `run`, finding the tweens on an element reads one index per timeline, not every tween. */
|
|
15
|
+
export function withTweenIndex<T>(run: () => T): T {
|
|
16
|
+
const outer = tweenIndexes;
|
|
17
|
+
tweenIndexes ??= new WeakMap();
|
|
18
|
+
try {
|
|
19
|
+
return run();
|
|
20
|
+
} finally {
|
|
21
|
+
tweenIndexes = outer;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function indexTweens(children: RuntimeTween[]): TweenIndex {
|
|
26
|
+
const index: TweenIndex = { byElement: new Map(), byId: new Map() };
|
|
27
|
+
const add = <K>(map: Map<K, RuntimeTween[]>, key: K, tween: RuntimeTween) => {
|
|
28
|
+
const list = map.get(key);
|
|
29
|
+
// A tween's targets are added together, so a repeat of its key is always the last entry.
|
|
30
|
+
if (!list) map.set(key, [tween]);
|
|
31
|
+
else if (list.at(-1) !== tween) list.push(tween);
|
|
32
|
+
};
|
|
33
|
+
for (const tween of children) {
|
|
34
|
+
for (const target of tween.targets?.() ?? []) {
|
|
35
|
+
add(index.byElement, target, tween);
|
|
36
|
+
const id = (target as Element).id;
|
|
37
|
+
if (id) add(index.byId, id, tween);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return index;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function tweensTargeting(
|
|
44
|
+
timeline: RuntimeTimeline | undefined,
|
|
45
|
+
el: Element,
|
|
46
|
+
): RuntimeTween[] {
|
|
47
|
+
if (!timeline || typeof timeline !== "object" || !tweenIndexes)
|
|
48
|
+
return (timeline?.getChildren?.(true) ?? []).filter((tween) => matchesElement(tween, el));
|
|
49
|
+
let index = tweenIndexes.get(timeline);
|
|
50
|
+
if (!index) tweenIndexes.set(timeline, (index = indexTweens(timeline.getChildren?.(true) ?? [])));
|
|
51
|
+
// The same rule as matchesElement: an element with an id matches every tween on that id.
|
|
52
|
+
return (el.id ? index.byId.get(el.id) : index.byElement.get(el)) ?? [];
|
|
53
|
+
}
|
|
@@ -1,33 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
-
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
3
|
import {
|
|
5
4
|
idFromSelector,
|
|
6
5
|
idSelector,
|
|
7
6
|
isInstantHold,
|
|
8
7
|
parsePercentageKeyframes,
|
|
9
8
|
resolveClipTimingBasis,
|
|
10
|
-
|
|
9
|
+
playsNear,
|
|
11
10
|
toClipKeyframes,
|
|
12
11
|
toClipPercentage,
|
|
13
12
|
} from "./gsapShared";
|
|
14
13
|
|
|
15
|
-
// Fixtures carry only the fields the function under test reads; the double-cast
|
|
16
|
-
// is the documented way to stand in for the full runtime shape (CONTRIBUTING.md).
|
|
17
|
-
const tween = (duration: number | undefined) => ({ duration }) as unknown as GsapAnimation;
|
|
18
|
-
|
|
19
|
-
describe("resolveEditableTweenDuration", () => {
|
|
20
|
-
const selection = { dataAttributes: { duration: "16.26" } } as unknown as DomEditSelection;
|
|
21
|
-
|
|
22
|
-
it("uses the owning clip duration when the tween omits an outer duration", () => {
|
|
23
|
-
expect(resolveEditableTweenDuration(tween(undefined), selection)).toBe(16.26);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("keeps an explicitly-authored tween duration", () => {
|
|
27
|
-
expect(resolveEditableTweenDuration(tween(4), selection)).toBe(4);
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
14
|
describe("isInstantHold", () => {
|
|
32
15
|
const animation = (method: GsapAnimation["method"], duration?: number) =>
|
|
33
16
|
({ method, duration }) as unknown as GsapAnimation;
|
|
@@ -54,45 +37,49 @@ describe("parsePercentageKeyframes", () => {
|
|
|
54
37
|
]);
|
|
55
38
|
});
|
|
56
39
|
|
|
57
|
-
it("parses GSAP array-form keyframes as
|
|
58
|
-
//
|
|
59
|
-
//
|
|
40
|
+
it("parses GSAP array-form keyframes at each step's end, as the parser does", () => {
|
|
41
|
+
// A multi-point shuttle path authored as `keyframes: [...]` has no `N%` keys;
|
|
42
|
+
// step i of n ends at (i+1)/n.
|
|
60
43
|
const out = parsePercentageKeyframes([
|
|
61
44
|
{ x: 0, y: 0 },
|
|
62
45
|
{ x: 520, y: 120 },
|
|
63
46
|
{ x: 1040, y: 0 },
|
|
64
47
|
{ x: 1480, y: 160 },
|
|
65
48
|
] as unknown as Record<string, unknown>);
|
|
66
|
-
expect(out?.keyframes.map((k) => k.percentage)).toEqual([
|
|
49
|
+
expect(out?.keyframes.map((k) => k.percentage)).toEqual([25, 50, 75, 100]);
|
|
67
50
|
expect(out?.keyframes.map((k) => k.step)).toEqual([0, 1, 2, 3]);
|
|
68
51
|
expect(out?.keyframes[1]!.properties).toEqual({ x: 520, y: 120 });
|
|
52
|
+
const timed = parsePercentageKeyframes([
|
|
53
|
+
{ x: 0, duration: 1 },
|
|
54
|
+
{ x: 50, duration: 3 },
|
|
55
|
+
] as unknown as Record<string, unknown>);
|
|
56
|
+
expect(timed?.keyframes.map((k) => k.percentage)).toEqual([25, 100]);
|
|
69
57
|
});
|
|
70
58
|
|
|
71
|
-
it("strips a per-entry ease without shifting the
|
|
59
|
+
it("strips a per-entry ease without shifting the step spacing of the others", () => {
|
|
72
60
|
// GSAP positions array keyframes by array index, so a `{ ease }` carried on an
|
|
73
61
|
// entry is a segment ease (skipped as a property) — it must not change where
|
|
74
|
-
// the surrounding keyframes land. 3 entries →
|
|
62
|
+
// the surrounding keyframes land. 3 entries → 33.3 / 66.7 / 100, even though the
|
|
75
63
|
// middle entry also carries an ease.
|
|
76
64
|
const out = parsePercentageKeyframes([
|
|
77
65
|
{ x: 0 },
|
|
78
66
|
{ x: 100, ease: "power2.in" },
|
|
79
67
|
{ x: 200 },
|
|
80
68
|
] as unknown as Record<string, unknown>);
|
|
81
|
-
expect(out?.keyframes.map((k) => k.percentage)).toEqual([
|
|
69
|
+
expect(out?.keyframes.map((k) => k.percentage)).toEqual([33.3, 66.7, 100]);
|
|
82
70
|
expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 100 }, { x: 200 }]);
|
|
83
71
|
});
|
|
84
72
|
|
|
85
|
-
it("keeps
|
|
73
|
+
it("keeps the step spacing when an interior array slot has no animatable prop", () => {
|
|
86
74
|
// A degenerate `{ ease }`-only slot contributes no output keyframe, but it is
|
|
87
75
|
// still an array slot GSAP allocates a position to — so the remaining entries
|
|
88
|
-
// keep their
|
|
89
|
-
// 0/100 collapsed onto a 2-entry spacing.
|
|
76
|
+
// keep their 3-slot percentages (33.3 and 100), not a 2-entry spacing.
|
|
90
77
|
const out = parsePercentageKeyframes([
|
|
91
78
|
{ x: 0 },
|
|
92
79
|
{ ease: "power2.in" },
|
|
93
80
|
{ x: 200 },
|
|
94
81
|
] as unknown as Record<string, unknown>);
|
|
95
|
-
expect(out?.keyframes.map((k) => k.percentage)).toEqual([
|
|
82
|
+
expect(out?.keyframes.map((k) => k.percentage)).toEqual([33.3, 100]);
|
|
96
83
|
expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 200 }]);
|
|
97
84
|
});
|
|
98
85
|
|
|
@@ -157,12 +144,38 @@ describe("toClipKeyframes", () => {
|
|
|
157
144
|
resolvedStart: 0,
|
|
158
145
|
} as unknown as GsapAnimation;
|
|
159
146
|
|
|
160
|
-
|
|
161
|
-
// (resolveEditableTweenDuration), so the cache rows have to agree: a fixed 1s
|
|
162
|
-
// basis put the end keyframe at 25% of a 4s clip instead of 100%.
|
|
163
|
-
it("spans the clip when the tween has no duration", () => {
|
|
147
|
+
it("places a duration-less tween's keyframes where GSAP plays them, not across the clip", () => {
|
|
164
148
|
const rows = toClipKeyframes([{ percentage: 0 }, { percentage: 100 }], durationless, 0, 4);
|
|
165
|
-
expect(rows.map((row) => row.percentage)).toEqual([0,
|
|
149
|
+
expect(rows.map((row) => row.percentage)).toEqual([0, 12.5]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("matches a keyframe by the time it plays, not its warped progress", () => {
|
|
153
|
+
const eased = {
|
|
154
|
+
...durationless,
|
|
155
|
+
duration: 1,
|
|
156
|
+
ease: "power2.in",
|
|
157
|
+
keyframes: { format: "percentage", keyframes: [] },
|
|
158
|
+
} as unknown as GsapAnimation;
|
|
159
|
+
// At 20% of the time power2.in (cubic) shows 0.8% progress: close in progress, far in time.
|
|
160
|
+
expect(playsNear(eased, 0, 0.8)).toBe(false);
|
|
161
|
+
expect(playsNear(eased, 50, 50.5)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("places a keyframe where an outer ease makes GSAP reach it", () => {
|
|
165
|
+
const eased = {
|
|
166
|
+
...durationless,
|
|
167
|
+
duration: 1,
|
|
168
|
+
ease: "power2.in",
|
|
169
|
+
keyframes: { format: "percentage", keyframes: [] },
|
|
170
|
+
} as unknown as GsapAnimation;
|
|
171
|
+
const rows = toClipKeyframes(
|
|
172
|
+
[{ percentage: 0 }, { percentage: 50 }, { percentage: 100 }],
|
|
173
|
+
eased,
|
|
174
|
+
0,
|
|
175
|
+
1,
|
|
176
|
+
);
|
|
177
|
+
// GSAP's power2 is cubic: it reaches 50% at the cube root of 0.5.
|
|
178
|
+
expect(rows.map((row) => row.percentage)).toEqual([0, 79.37, 100]);
|
|
166
179
|
});
|
|
167
180
|
|
|
168
181
|
it("keeps the tween percentage and the animation identity on every row", () => {
|
|
@@ -276,7 +289,7 @@ describe("sub-composition keyframe percentages", () => {
|
|
|
276
289
|
|
|
277
290
|
it("puts a tween on the host's first frame at 0%, never below zero", () => {
|
|
278
291
|
// A clip-relative percentage can never be negative; this one cached -12.
|
|
279
|
-
expect(percentages(inner(0))).toEqual([0,
|
|
292
|
+
expect(percentages(inner(0))).toEqual([0, 4]);
|
|
280
293
|
});
|
|
281
294
|
|
|
282
295
|
it("puts the last tween's end keyframe at 100%", () => {
|
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
7
7
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
8
|
+
import { getObjectArrayKeyframeTiming } from "@hyperframes/parsers/gsap-parser";
|
|
9
|
+
import { progressAtTime, runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
|
|
8
10
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
9
11
|
import {
|
|
10
12
|
absoluteToPercentage,
|
|
@@ -88,6 +90,13 @@ const SAFE_HASH_ID = /^-?[A-Za-z_][\w-]*$/;
|
|
|
88
90
|
*/
|
|
89
91
|
export const KEYFRAME_PCT_MATCH = 1;
|
|
90
92
|
|
|
93
|
+
/** Whether the keyframe at `percentage` plays within KEYFRAME_PCT_MATCH of the time where GSAP shows `progress`. */
|
|
94
|
+
export function playsNear(anim: GsapAnimation, percentage: number, progress: number): boolean {
|
|
95
|
+
const runEase = runEaseOf(anim);
|
|
96
|
+
const gap = timeAtProgress(runEase, percentage) - timeAtProgress(runEase, progress);
|
|
97
|
+
return Math.abs(gap) <= KEYFRAME_PCT_MATCH;
|
|
98
|
+
}
|
|
99
|
+
|
|
91
100
|
export function idSelector(id: string): string {
|
|
92
101
|
// A `#id` selector is only valid for a CSS identifier. IDs that start with a
|
|
93
102
|
// digit (e.g. "01-hook-hero-word") make `document.querySelector("#01-...")` and
|
|
@@ -332,22 +341,6 @@ export function tweenTargetsElement(
|
|
|
332
341
|
|
|
333
342
|
// ── Percentage computation ────────────────────────────────────────────────────
|
|
334
343
|
|
|
335
|
-
/**
|
|
336
|
-
* Resolve the timing basis used by editor keyframes. The timeline renders a
|
|
337
|
-
* duration-less tween across its owning clip, so mutations must use that same
|
|
338
|
-
* duration instead of silently falling back to GSAP's 0.5s default.
|
|
339
|
-
*/
|
|
340
|
-
export function resolveEditableTweenDuration(
|
|
341
|
-
animation: GsapAnimation,
|
|
342
|
-
selection: DomEditSelection,
|
|
343
|
-
): number {
|
|
344
|
-
const clipDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "");
|
|
345
|
-
return resolveTweenDuration(
|
|
346
|
-
animation,
|
|
347
|
-
Number.isFinite(clipDuration) && clipDuration > 0 ? clipDuration : 0.5,
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
344
|
/**
|
|
352
345
|
* Compute the current playback percentage within an element's animation range.
|
|
353
346
|
* Uses the animation's resolved timing if available, otherwise falls back to
|
|
@@ -360,10 +353,13 @@ export function computeElementPercentage(
|
|
|
360
353
|
): number {
|
|
361
354
|
if (animation) {
|
|
362
355
|
const start = resolveTweenStart(animation);
|
|
363
|
-
const duration =
|
|
356
|
+
const duration = resolveTweenDuration(animation);
|
|
364
357
|
if (duration <= 0) return 0;
|
|
365
358
|
if (start !== null) {
|
|
366
|
-
return
|
|
359
|
+
return progressAtTime(
|
|
360
|
+
runEaseOf(animation),
|
|
361
|
+
absoluteToPercentage(currentTime, start, duration),
|
|
362
|
+
);
|
|
367
363
|
}
|
|
368
364
|
}
|
|
369
365
|
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
|
|
@@ -428,13 +424,9 @@ export function parsePercentageKeyframes(
|
|
|
428
424
|
const keyframes: ParsedPercentageKeyframes["keyframes"] = [];
|
|
429
425
|
let easeEach: string | undefined;
|
|
430
426
|
|
|
431
|
-
// GSAP array-form keyframes — `keyframes: [{x,y}, {x,y}, ...]` —
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
// studio never emits. So entry i of n maps to i/(n-1)*100% (n=4 → 0/33.3/66.7/100).
|
|
435
|
-
// Index spacing counts EVERY array slot, including a degenerate entry that
|
|
436
|
-
// contributes no animatable prop (it's still a slot GSAP allocates a position
|
|
437
|
-
// to), so dropping such an entry from the output below must NOT shift the others.
|
|
427
|
+
// GSAP array-form keyframes — `keyframes: [{x,y}, {x,y}, ...]` — place each step at
|
|
428
|
+
// its cumulative end, the parser's rule (getObjectArrayKeyframeTiming). Spacing counts
|
|
429
|
+
// EVERY array slot, so dropping an entry with no animatable prop must NOT shift the others.
|
|
438
430
|
// A per-entry `ease` is a segment ease, not a keyframe value, so it's skipped as
|
|
439
431
|
// a property; there is no array-form `easeEach` (that's an object-form sibling key).
|
|
440
432
|
// (The object form further down uses explicit "0%" keys instead.) Without this
|
|
@@ -442,11 +434,16 @@ export function parsePercentageKeyframes(
|
|
|
442
434
|
// motion path.
|
|
443
435
|
if (Array.isArray(kfObj)) {
|
|
444
436
|
const steps = kfObj as unknown[];
|
|
437
|
+
const timing = getObjectArrayKeyframeTiming(
|
|
438
|
+
steps.map((entry) => (entry as { duration?: unknown } | null)?.duration),
|
|
439
|
+
);
|
|
440
|
+
if (!timing) return null;
|
|
445
441
|
steps.forEach((entry, i) => {
|
|
446
442
|
if (!entry || typeof entry !== "object") return;
|
|
447
|
-
const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
|
|
448
443
|
const properties = collectAnimatableKeyframeProperties(entry);
|
|
449
|
-
if (Object.keys(properties).length > 0)
|
|
444
|
+
if (Object.keys(properties).length > 0) {
|
|
445
|
+
keyframes.push({ percentage: timing.percentages[i]!, properties, step: i });
|
|
446
|
+
}
|
|
450
447
|
});
|
|
451
448
|
return keyframes.length > 0 ? { keyframes } : null;
|
|
452
449
|
}
|
|
@@ -575,14 +572,15 @@ export function toClipKeyframes<T extends { percentage: number }>(
|
|
|
575
572
|
}
|
|
576
573
|
> {
|
|
577
574
|
const tweenStart = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
578
|
-
|
|
579
|
-
// (resolveEditableTweenDuration). A fixed 1s here put its keyframes at a
|
|
580
|
-
// percentage no editor agreed with.
|
|
581
|
-
const tweenDuration = anim.duration ?? clipDuration;
|
|
575
|
+
const tweenDuration = resolveTweenDuration(anim);
|
|
582
576
|
return source.map((keyframe) => ({
|
|
583
577
|
...keyframe,
|
|
584
578
|
percentage: toClipPercentage(
|
|
585
|
-
toAbsoluteTime(
|
|
579
|
+
toAbsoluteTime(
|
|
580
|
+
tweenStart,
|
|
581
|
+
tweenDuration,
|
|
582
|
+
timeAtProgress(runEaseOf(anim), keyframe.percentage),
|
|
583
|
+
),
|
|
586
584
|
clipStart,
|
|
587
585
|
clipDuration,
|
|
588
586
|
keyframe.percentage,
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { gsap } from "gsap";
|
|
3
3
|
import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
replaceTweenWithKeyframesInScript,
|
|
6
|
+
syncPositionHoldsBeforeKeyframes,
|
|
7
|
+
updateKeyframeInScript,
|
|
8
|
+
} from "@hyperframes/parsers/gsap-writer-acorn";
|
|
5
9
|
import { afterEach, expect, it, vi } from "vitest";
|
|
6
10
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
11
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
12
|
import { findParsedTween, parsedImplicitEndValue, parsedTweenEase } from "./gsapParsedTween";
|
|
9
13
|
import { readRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
10
14
|
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
15
|
+
import { toClipKeyframes } from "./gsapShared";
|
|
11
16
|
import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
|
|
17
|
+
import { applyKeyframeAtPlayhead, type EnableKeyframesSession } from "./useEnableKeyframes";
|
|
12
18
|
|
|
13
19
|
/** Runs a composition script as the preview does: a paused timeline, bound, then seeked to `at`. */
|
|
14
20
|
function play(script: string, at: number) {
|
|
@@ -74,10 +80,10 @@ it("reads plain array nodes where the lane puts them, though GSAP fills in step
|
|
|
74
80
|
});
|
|
75
81
|
|
|
76
82
|
// Three default 0.5 s steps stretched over 3 s: GSAP reaches the middle one at 2 s, where the parse
|
|
77
|
-
// places it at
|
|
83
|
+
// places it at 66.7%.
|
|
78
84
|
it.each([
|
|
79
85
|
["at the playhead, timing array steps on their own timeline", undefined],
|
|
80
|
-
["selected in the lane, in place",
|
|
86
|
+
["selected in the lane, in place", 66.7],
|
|
81
87
|
])("changes the middle array keyframe %s", (_, selectedPct) => {
|
|
82
88
|
const { plan, shown } = dragAndReplay(
|
|
83
89
|
script("keyframes: [{ x: 60 }, { x: 120 }, { x: 180 }], duration: 3"),
|
|
@@ -244,6 +250,90 @@ it("writes an edit into a looping tween and keeps its repeat and yoyo, as main d
|
|
|
244
250
|
expect(written).toMatch(/repeat: 1[\s\S]*yoyo: true/);
|
|
245
251
|
});
|
|
246
252
|
|
|
253
|
+
it("keeps a delayed step list where GSAP plays it, before and after a keyframe edit", () => {
|
|
254
|
+
const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
|
|
255
|
+
const stepList = `var tl = gsap.timeline({ paused: true });\ntl.to("#x", { keyframes: [{ x: 60 }, { x: 120 }], ease: "none" }, 1);\nwindow.__timelines["t"] = tl;`;
|
|
256
|
+
const id = parseGsapScriptAcorn(stepList).animations[0]!.id;
|
|
257
|
+
const edited = syncPositionHoldsBeforeKeyframes(
|
|
258
|
+
updateKeyframeInScript(stepList, id, 100, { x: 130 }),
|
|
259
|
+
);
|
|
260
|
+
const xAt = (file: string, at: number) => {
|
|
261
|
+
const { timeline } = play(file, at);
|
|
262
|
+
const x = gsap.getProperty(box, "x");
|
|
263
|
+
timeline.kill();
|
|
264
|
+
gsap.set(box, { clearProps: "all" });
|
|
265
|
+
return x;
|
|
266
|
+
};
|
|
267
|
+
for (const file of [stepList, edited]) {
|
|
268
|
+
expect([xAt(file, 0.5), xAt(file, 1.25)]).toEqual([0, 30]);
|
|
269
|
+
const tween = parseGsapScriptAcorn(file).animations.find((a) => a.keyframes)!;
|
|
270
|
+
for (const { percentage, properties } of tween.keyframes!.keyframes) {
|
|
271
|
+
expect(xAt(file, 1 + percentage / 100)).toBeCloseTo(Number(properties.x), 3);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it.each([
|
|
277
|
+
[
|
|
278
|
+
"an outer power2.in",
|
|
279
|
+
`keyframes: { "0%": { x: 0 }, "50%": { x: 60 }, "100%": { x: 120 } }, duration: 1, ease: "power2.in"`,
|
|
280
|
+
],
|
|
281
|
+
["no duration", `keyframes: [{ x: 60 }, { x: 120 }], ease: "none"`],
|
|
282
|
+
])("draws each diamond of a keyframed tween with %s where GSAP shows its value", (_, vars) => {
|
|
283
|
+
const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
|
|
284
|
+
const file = `var tl = gsap.timeline({ paused: true });\ntl.to("#x", { ${vars} }, 1);\nwindow.__timelines["t"] = tl;`;
|
|
285
|
+
const tween = parseGsapScriptAcornAnimation(file);
|
|
286
|
+
for (const row of toClipKeyframes(tween.keyframes!.keyframes, tween, 0, 4)) {
|
|
287
|
+
const { timeline } = play(file, (row.percentage / 100) * 4);
|
|
288
|
+
expect(gsap.getProperty(box, "x")).toBeCloseTo(Number(row.properties.x), 1);
|
|
289
|
+
timeline.kill();
|
|
290
|
+
gsap.set(box, { clearProps: "all" });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
function parseGsapScriptAcornAnimation(file: string) {
|
|
295
|
+
return parseGsapScriptAcorn(file).animations.find((a) => a.keyframes)!;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
it("keeps a step list's steps where GSAP plays them when an outer duration stretches it", () => {
|
|
299
|
+
const { plan, written } = dragAndReplay(
|
|
300
|
+
script(`keyframes: [{ x: 100 }, { x: 200 }], duration: 2, ease: "none"`),
|
|
301
|
+
70,
|
|
302
|
+
0.5,
|
|
303
|
+
);
|
|
304
|
+
expect(plan.ok).toBe(true);
|
|
305
|
+
const box = document.getElementById("x")!;
|
|
306
|
+
const xAt = (at: number) => {
|
|
307
|
+
const { timeline } = play(written!, at);
|
|
308
|
+
const x = gsap.getProperty(box, "x");
|
|
309
|
+
timeline.kill();
|
|
310
|
+
return x;
|
|
311
|
+
};
|
|
312
|
+
expect([xAt(0.5), xAt(1), xAt(2)]).toEqual([70, 100, 200]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("adds a keyframe where GSAP plays a tween whose duration is an expression, and never rewrites the expression", async () => {
|
|
316
|
+
const src = `var dur = () => 2;\nvar tl = gsap.timeline({ paused: true });\ntl.to("#x", { keyframes: { "0%": { x: 0 }, "100%": { x: 300 } }, duration: dur() }, 1);\nwindow.__timelines["t"] = tl;`;
|
|
317
|
+
const box = document.body.appendChild(document.createElement("div"));
|
|
318
|
+
box.id = "x";
|
|
319
|
+
const { timeline, iframe } = play(src, 2);
|
|
320
|
+
const anim = parseGsapScriptAcorn(src).animations[0]!;
|
|
321
|
+
const writes: Array<[string, unknown]> = [];
|
|
322
|
+
const session = {
|
|
323
|
+
commitMutation: async (mutation: unknown) => void writes.push(["replace", mutation]),
|
|
324
|
+
handleGsapRemoveKeyframe: () => void writes.push(["remove", null]),
|
|
325
|
+
handleGsapAddKeyframeBatch: async (_id: string, pct: number) => void writes.push(["add", pct]),
|
|
326
|
+
} as unknown as EnableKeyframesSession;
|
|
327
|
+
const selection = { id: "x", selector: "#x", element: box } as DomEditSelection;
|
|
328
|
+
|
|
329
|
+
await applyKeyframeAtPlayhead(session, selection, anim, 2, iframe);
|
|
330
|
+
await applyKeyframeAtPlayhead(session, selection, anim, 3.5, iframe);
|
|
331
|
+
timeline.kill();
|
|
332
|
+
|
|
333
|
+
expect(anim.durationUnresolved).toBe(true);
|
|
334
|
+
expect(writes).toEqual([["add", 50]]);
|
|
335
|
+
});
|
|
336
|
+
|
|
247
337
|
it("leaves a step list with a runBackwards step to the runtime: a rewrite would play it differently", async () => {
|
|
248
338
|
const flagged = script(
|
|
249
339
|
"keyframes: [{ x: 60, duration: 1, runBackwards: true }, { x: 180, duration: 1 }], ease: 'none'",
|