@hyperframes/studio 0.6.122-alpha.0 → 0.7.0
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/index-CbNg8Ghm.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
- package/dist/assets/{index-i-MLqhmm.js → index-DsxCdV8b.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3757 -2232
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -39
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +7 -1
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-Baz2T-9U.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- package/src/player/components/TimelinePropertyRows.tsx +0 -126
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parsePercentageKeyframes } from "./gsapShared";
|
|
3
|
+
|
|
4
|
+
describe("parsePercentageKeyframes", () => {
|
|
5
|
+
it("parses the object/percentage form", () => {
|
|
6
|
+
const out = parsePercentageKeyframes({ "0%": { x: 0, y: 0 }, "100%": { x: 9, y: 4 } });
|
|
7
|
+
expect(out?.keyframes).toEqual([
|
|
8
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
9
|
+
{ percentage: 100, properties: { x: 9, y: 4 } },
|
|
10
|
+
]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("parses GSAP array-form keyframes as evenly-distributed steps", () => {
|
|
14
|
+
// Regression: a multi-point shuttle path authored as `keyframes: [...]` used to
|
|
15
|
+
// read as null (no `N%` keys) → no motion path. Steps map to i/(n-1)*100%.
|
|
16
|
+
const out = parsePercentageKeyframes([
|
|
17
|
+
{ x: 0, y: 0 },
|
|
18
|
+
{ x: 520, y: 120 },
|
|
19
|
+
{ x: 1040, y: 0 },
|
|
20
|
+
{ x: 1480, y: 160 },
|
|
21
|
+
] as unknown as Record<string, unknown>);
|
|
22
|
+
expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 33.3, 66.7, 100]);
|
|
23
|
+
expect(out?.keyframes[1]!.properties).toEqual({ x: 520, y: 120 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("strips a per-entry ease without shifting the even index-spacing of the others", () => {
|
|
27
|
+
// GSAP positions array keyframes by array index, so a `{ ease }` carried on an
|
|
28
|
+
// entry is a segment ease (skipped as a property) — it must not change where
|
|
29
|
+
// the surrounding keyframes land. 3 entries → 0 / 50 / 100, even though the
|
|
30
|
+
// middle entry also carries an ease.
|
|
31
|
+
const out = parsePercentageKeyframes([
|
|
32
|
+
{ x: 0 },
|
|
33
|
+
{ x: 100, ease: "power2.in" },
|
|
34
|
+
{ x: 200 },
|
|
35
|
+
] as unknown as Record<string, unknown>);
|
|
36
|
+
expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 50, 100]);
|
|
37
|
+
expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 100 }, { x: 200 }]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("keeps even spacing when an interior array slot has no animatable prop", () => {
|
|
41
|
+
// A degenerate `{ ease }`-only slot contributes no output keyframe, but it is
|
|
42
|
+
// still an array slot GSAP allocates a position to — so the remaining entries
|
|
43
|
+
// keep their original i/(n-1) percentages (0 and 100 for a 3-slot array), not
|
|
44
|
+
// 0/100 collapsed onto a 2-entry spacing.
|
|
45
|
+
const out = parsePercentageKeyframes([
|
|
46
|
+
{ x: 0 },
|
|
47
|
+
{ ease: "power2.in" },
|
|
48
|
+
{ x: 200 },
|
|
49
|
+
] as unknown as Record<string, unknown>);
|
|
50
|
+
expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 100]);
|
|
51
|
+
expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 200 }]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns null for keyframes with no positional/animatable props", () => {
|
|
55
|
+
expect(parsePercentageKeyframes([] as unknown as Record<string, unknown>)).toBeNull();
|
|
56
|
+
expect(parsePercentageKeyframes({})).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -97,16 +97,6 @@ export function queryIframeElement(
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/** Safely access an iframe's contentDocument, returning null on cross-origin errors. */
|
|
101
|
-
export function getIframeDocument(iframe: HTMLIFrameElement | null): Document | null {
|
|
102
|
-
if (!iframe) return null;
|
|
103
|
-
try {
|
|
104
|
-
return iframe.contentDocument;
|
|
105
|
-
} catch {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
100
|
// ── Keyframe parsing ──────────────────────────────────────────────────────────
|
|
111
101
|
|
|
112
102
|
export interface ParsedPercentageKeyframes {
|
|
@@ -125,6 +115,34 @@ export function parsePercentageKeyframes(
|
|
|
125
115
|
const keyframes: ParsedPercentageKeyframes["keyframes"] = [];
|
|
126
116
|
let easeEach: string | undefined;
|
|
127
117
|
|
|
118
|
+
// GSAP array-form keyframes — `keyframes: [{x,y}, {x,y}, ...]` — are spread
|
|
119
|
+
// evenly across the tween by default: GSAP gives each entry an equal share of
|
|
120
|
+
// the duration unless an entry carries its own `duration`/`delay`, which the
|
|
121
|
+
// studio never emits. So entry i of n maps to i/(n-1)*100% (n=4 → 0/33.3/66.7/100).
|
|
122
|
+
// Index spacing counts EVERY array slot, including a degenerate entry that
|
|
123
|
+
// contributes no animatable prop (it's still a slot GSAP allocates a position
|
|
124
|
+
// to), so dropping such an entry from the output below must NOT shift the others.
|
|
125
|
+
// A per-entry `ease` is a segment ease, not a keyframe value, so it's skipped as
|
|
126
|
+
// a property; there is no array-form `easeEach` (that's an object-form sibling key).
|
|
127
|
+
// (The object form further down uses explicit "0%" keys instead.) Without this
|
|
128
|
+
// branch, array-keyframed tweens (e.g. a multi-point shuttle) read as null → no
|
|
129
|
+
// motion path.
|
|
130
|
+
if (Array.isArray(kfObj)) {
|
|
131
|
+
const steps = kfObj as unknown[];
|
|
132
|
+
steps.forEach((entry, i) => {
|
|
133
|
+
if (!entry || typeof entry !== "object") return;
|
|
134
|
+
const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
|
|
135
|
+
const properties: Record<string, number | string> = {};
|
|
136
|
+
for (const [pk, pv] of Object.entries(entry as Record<string, unknown>)) {
|
|
137
|
+
if (pk === "ease") continue;
|
|
138
|
+
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
|
139
|
+
else if (typeof pv === "string") properties[pk] = pv;
|
|
140
|
+
}
|
|
141
|
+
if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties });
|
|
142
|
+
});
|
|
143
|
+
return keyframes.length > 0 ? { keyframes } : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
128
146
|
for (const [key, val] of Object.entries(kfObj)) {
|
|
129
147
|
if (key === "easeEach") {
|
|
130
148
|
if (typeof val === "string") easeEach = val;
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
2
|
-
|
|
3
1
|
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
|
|
4
2
|
|
|
5
3
|
let _gsapCachedTimelines: Record<string, TimelineLike> | undefined;
|
|
@@ -50,7 +48,6 @@ export function isElementGsapTargeted(
|
|
|
50
48
|
iframe: HTMLIFrameElement | null,
|
|
51
49
|
element: HTMLElement,
|
|
52
50
|
): boolean {
|
|
53
|
-
if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false;
|
|
54
51
|
const timelines = readTimelines(iframe);
|
|
55
52
|
if (!timelines) return false;
|
|
56
53
|
|
|
@@ -213,10 +213,8 @@ export function useDomEditSession({
|
|
|
213
213
|
handleDomTextFieldStyleCommit,
|
|
214
214
|
handleDomAddTextField,
|
|
215
215
|
handleDomRemoveTextField,
|
|
216
|
-
handleDomPathOffsetCommit,
|
|
217
216
|
handleDomGroupPathOffsetCommit,
|
|
218
217
|
handleDomBoxSizeCommit,
|
|
219
|
-
handleDomRotationCommit,
|
|
220
218
|
handleDomManualEditsReset,
|
|
221
219
|
handleDomEditElementDelete,
|
|
222
220
|
handleDomZIndexReorderCommit,
|
|
@@ -384,9 +382,7 @@ export function useDomEditSession({
|
|
|
384
382
|
bumpGsapCache,
|
|
385
383
|
makeFetchFallback,
|
|
386
384
|
trackGsapInteractionFailure,
|
|
387
|
-
handleDomPathOffsetCommit,
|
|
388
385
|
handleDomBoxSizeCommit,
|
|
389
|
-
handleDomRotationCommit,
|
|
390
386
|
addGsapAnimation,
|
|
391
387
|
convertToKeyframes,
|
|
392
388
|
setArcPath,
|
|
@@ -4,7 +4,11 @@ import {
|
|
|
4
4
|
getAllPreviewTargetsFromPointer,
|
|
5
5
|
getPreviewTargetFromPointer,
|
|
6
6
|
} from "../utils/studioPreviewHelpers";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
findMatchingTimelineElementId,
|
|
9
|
+
findTimelineIdByAncestor,
|
|
10
|
+
type RightPanelTab,
|
|
11
|
+
} from "../utils/studioHelpers";
|
|
8
12
|
import {
|
|
9
13
|
domEditSelectionsTargetSame,
|
|
10
14
|
domEditSelectionInGroup,
|
|
@@ -178,10 +182,13 @@ export function useDomSelection({
|
|
|
178
182
|
setRightCollapsed(false);
|
|
179
183
|
setRightPanelTab("design");
|
|
180
184
|
}
|
|
181
|
-
const nextSelectedTimelineId =
|
|
182
|
-
nextSelection,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
+
const nextSelectedTimelineId =
|
|
186
|
+
findMatchingTimelineElementId(nextSelection, timelineElements) ??
|
|
187
|
+
findTimelineIdByAncestor(
|
|
188
|
+
nextSelection.element,
|
|
189
|
+
timelineElements,
|
|
190
|
+
nextSelection.sourceFile || "index.html",
|
|
191
|
+
);
|
|
185
192
|
setSelectedTimelineElementId(nextSelectedTimelineId);
|
|
186
193
|
return;
|
|
187
194
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import {
|
|
4
|
+
animatedProps,
|
|
5
|
+
buildExtendedKeyframes,
|
|
6
|
+
isPlayheadWithinTween,
|
|
7
|
+
resolveNewTweenRange,
|
|
8
|
+
} from "./useEnableKeyframes";
|
|
9
|
+
|
|
10
|
+
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
|
|
11
|
+
return {
|
|
12
|
+
id: "#el-to-0-position",
|
|
13
|
+
targetSelector: "#el",
|
|
14
|
+
method: "to",
|
|
15
|
+
position: 0,
|
|
16
|
+
properties: {},
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("resolveNewTweenRange", () => {
|
|
22
|
+
// Regression: "add a keyframe" must land at the PLAYHEAD. The runtime auto-stamps
|
|
23
|
+
// data-start="0" + data-duration=<rootDuration> on every GSAP element, so honoring
|
|
24
|
+
// data-start as authored timing put the keyframe at 0. Clamping the playhead into
|
|
25
|
+
// the element's range fixes it (auto-stamp's full range passes the playhead through).
|
|
26
|
+
it("anchors at the playhead through the auto-stamped full-composition range", () => {
|
|
27
|
+
// data-start="0", data-duration="14" (the auto-stamp), playhead 4.9 → 4.9
|
|
28
|
+
expect(resolveNewTweenRange("0", "14", 4.9)).toEqual({ start: 4.9, duration: 9.1 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("anchors at the playhead when the element has no authored range", () => {
|
|
32
|
+
expect(resolveNewTweenRange(undefined, undefined, 4)).toEqual({ start: 4, duration: 1 });
|
|
33
|
+
expect(resolveNewTweenRange(undefined, undefined, 6.123456).start).toBe(6.123);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("never returns a negative start", () => {
|
|
37
|
+
expect(resolveNewTweenRange(undefined, undefined, -2).start).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("clamps the playhead into a genuinely narrow authored clip", () => {
|
|
41
|
+
// clip [2.5, 8]: inside → playhead; before → start; after → end
|
|
42
|
+
expect(resolveNewTweenRange("2.5", "5.5", 4)).toEqual({ start: 4, duration: 4 });
|
|
43
|
+
expect(resolveNewTweenRange("2.5", "5.5", 1).start).toBe(2.5);
|
|
44
|
+
expect(resolveNewTweenRange("2.5", "5.5", 99).start).toBe(8);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("animatedProps", () => {
|
|
49
|
+
it("uses top-level properties when present (flat tween)", () => {
|
|
50
|
+
expect(animatedProps(anim({ properties: { x: -260 } }))).toEqual(["x"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("derives props from keyframe stops when top-level properties is empty (array form)", () => {
|
|
54
|
+
// Regression: array-form `keyframes: [{x,y},…]` leaves `properties` empty, so
|
|
55
|
+
// add-keyframe read an empty prop list → empty position → silent no-op.
|
|
56
|
+
const a = anim({
|
|
57
|
+
properties: {},
|
|
58
|
+
keyframes: {
|
|
59
|
+
format: "object-array",
|
|
60
|
+
keyframes: [
|
|
61
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
62
|
+
{ percentage: 100, properties: { x: -460, y: -20 } },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
expect(animatedProps(a).sort()).toEqual(["x", "y"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("falls back to x/y for a null anim or one with no resolvable props", () => {
|
|
70
|
+
expect(animatedProps(null)).toEqual(["x", "y"]);
|
|
71
|
+
expect(animatedProps(anim({ properties: {} }))).toEqual(["x", "y"]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("isPlayheadWithinTween", () => {
|
|
76
|
+
const tween = anim({ position: 1.0, duration: 3.4 }); // range [1.0, 4.4]
|
|
77
|
+
|
|
78
|
+
it("is true inside the range (incl. boundaries)", () => {
|
|
79
|
+
expect(isPlayheadWithinTween(tween, 3.0)).toBe(true);
|
|
80
|
+
expect(isPlayheadWithinTween(tween, 1.0)).toBe(true);
|
|
81
|
+
expect(isPlayheadWithinTween(tween, 4.4)).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("is false outside the tween range", () => {
|
|
85
|
+
expect(isPlayheadWithinTween(tween, 5.767)).toBe(false);
|
|
86
|
+
expect(isPlayheadWithinTween(tween, 0.5)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not block when the start can't be resolved", () => {
|
|
90
|
+
expect(isPlayheadWithinTween(anim({ position: "+=1" }), 99)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("buildExtendedKeyframes", () => {
|
|
95
|
+
// puck-b: tween [1.0, 4.4], four evenly-distributed stops.
|
|
96
|
+
const kfAnim = anim({
|
|
97
|
+
position: 1.0,
|
|
98
|
+
duration: 3.4,
|
|
99
|
+
keyframes: {
|
|
100
|
+
format: "object-array",
|
|
101
|
+
keyframes: [
|
|
102
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
103
|
+
{ percentage: 33.3, properties: { x: -180, y: -60 } },
|
|
104
|
+
{ percentage: 66.7, properties: { x: -320, y: 40 } },
|
|
105
|
+
{ percentage: 100, properties: { x: -460, y: -20 } },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("extends the end and rescales existing stops to keep their absolute timing", () => {
|
|
111
|
+
const out = buildExtendedKeyframes(kfAnim, 5.767, { x: -460, y: -20 });
|
|
112
|
+
expect(out.position).toBe(1.0); // start unchanged
|
|
113
|
+
expect(out.duration).toBe(4.767); // grown to reach the playhead
|
|
114
|
+
// old end (abs 4.4) is no longer 100% — it slid back inside the longer range
|
|
115
|
+
const last = out.keyframes[out.keyframes.length - 1]!;
|
|
116
|
+
expect(last.percentage).toBe(100); // the new keyframe sits at the new end
|
|
117
|
+
expect(last.properties).toEqual({ x: -460, y: -20 });
|
|
118
|
+
expect(out.keyframes[0]!.percentage).toBe(0); // old start still anchors 0%
|
|
119
|
+
expect(out.keyframes.some((k) => k.percentage > 0 && k.percentage < 100)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("extends the start when the playhead precedes the tween", () => {
|
|
123
|
+
const out = buildExtendedKeyframes(kfAnim, 0, { x: 0, y: 0 });
|
|
124
|
+
expect(out.position).toBe(0); // start moved back to the playhead
|
|
125
|
+
expect(out.duration).toBe(4.4); // end (abs 4.4) unchanged
|
|
126
|
+
expect(out.keyframes[0]).toEqual({ percentage: 0, properties: { x: 0, y: 0 } });
|
|
127
|
+
// the old first stop (abs 1.0) is now partway in: 1.0 / 4.4 ≈ 22.7%
|
|
128
|
+
expect(out.keyframes[1]!.percentage).toBeCloseTo(22.7, 1);
|
|
129
|
+
});
|
|
130
|
+
});
|