@hyperframes/studio 0.8.126 → 0.8.128
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BbbN-wWN.js → hyperframes-player-CKCx0CFA.js} +1 -1
- package/dist/assets/{index-Cll7uTNy.js → index-CF31Pq1E.js} +1 -1
- package/dist/assets/{index-B8kU2xXl.js → index-ClLzyuwb.js} +172 -172
- package/dist/assets/{index-nvLF29ZH.js → index-CwS08gWW.js} +1 -1
- package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
- package/dist/chunk-7UQWVN52.js.map +1 -0
- package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
- package/dist/chunk-DRVWEPO5.js.map +1 -0
- package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
- package/dist/chunk-TDWGXBUT.js.map +1 -0
- package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
- package/dist/index.d.ts +8 -9
- package/dist/index.html +1 -1
- package/dist/index.js +2184 -1745
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/components/TimelineToolbar.tsx +6 -7
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
- package/src/components/editor/MotionPathOverlay.tsx +20 -26
- package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
- package/src/components/editor/domEditingLayers.test.ts +45 -18
- package/src/components/editor/domEditingLayers.ts +11 -9
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/keyframeRetime.ts +11 -4
- package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
- package/src/components/editor/motionPathCommit.ts +58 -0
- package/src/components/editor/motionPathGeometry.test.ts +13 -0
- package/src/components/editor/motionPathGeometry.ts +4 -2
- package/src/components/editor/probeSourceElement.ts +17 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
- package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
- package/src/hooks/gsapDragCommit.ts +41 -6
- package/src/hooks/gsapDragPositionCommit.ts +90 -17
- package/src/hooks/gsapEditOutcome.ts +27 -2
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
- package/src/hooks/gsapMutationClient.ts +35 -7
- package/src/hooks/gsapOvershootEase.test.ts +67 -0
- package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
- package/src/hooks/gsapParsedTween.ts +27 -7
- package/src/hooks/gsapPositionDetection.ts +5 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
- package/src/hooks/gsapRuntimeBridge.ts +7 -1
- package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
- package/src/hooks/gsapRuntimeKeyframes.ts +20 -60
- package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
- package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
- package/src/hooks/gsapShared.test.ts +49 -35
- package/src/hooks/gsapShared.ts +35 -33
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +198 -6
- package/src/hooks/gsapValueAtPlayhead.test.ts +142 -27
- package/src/hooks/gsapValueAtPlayhead.ts +49 -10
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +40 -14
- package/src/hooks/timelineLinkEditPlan.ts +6 -4
- package/src/hooks/timelineMoveAdapter.ts +3 -5
- package/src/hooks/timelineTimingSync.test.ts +83 -125
- package/src/hooks/timelineTimingSync.ts +62 -42
- package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
- package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
- package/src/hooks/useCommitPreflightCapabilities.ts +44 -9
- package/src/hooks/useDomEditPreviewSync.ts +8 -2
- package/src/hooks/useDomSelection.test.ts +27 -0
- package/src/hooks/useDomSelection.ts +12 -13
- package/src/hooks/useDomSelectionTypes.ts +1 -0
- package/src/hooks/useEditHistoryActions.test.tsx +54 -3
- package/src/hooks/useEditHistoryActions.ts +9 -6
- package/src/hooks/useEnableKeyframes.test.ts +20 -15
- package/src/hooks/useEnableKeyframes.ts +54 -71
- package/src/hooks/useGestureCommit.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
- package/src/hooks/useGsapTweenCache.ts +2 -17
- package/src/hooks/usePersistentEditHistory.test.ts +45 -0
- package/src/hooks/usePersistentEditHistory.ts +13 -2
- package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
- package/src/hooks/usePreviewPersistence.ts +26 -4
- package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
- package/src/hooks/useTimelineDeleteOps.ts +5 -13
- package/src/hooks/useTimelineEditing.test.tsx +63 -4
- package/src/hooks/useTimelineEditing.ts +21 -295
- package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
- package/src/hooks/useTimelineGroupEditing.ts +15 -38
- package/src/player/components/Timeline.test.ts +12 -4
- package/src/player/components/TimelineClip.tsx +3 -0
- package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
- package/src/player/components/TimelineLanes.test.tsx +61 -5
- package/src/player/components/TimelineLanes.tsx +20 -28
- package/src/player/components/TimelinePropertyLanes.tsx +10 -1
- package/src/player/components/timelineCallbacks.ts +1 -4
- package/src/player/components/timelineClipDragCommit.test.ts +31 -0
- package/src/player/components/timelineClipDragCommit.ts +39 -28
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
- package/src/player/components/timelineClipGestureHandlers.ts +2 -20
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineGroupResizeCommit.ts +10 -9
- package/src/player/components/timelineLinkSelection.ts +1 -35
- package/src/player/components/timelineOptimisticRevision.ts +7 -4
- package/src/player/components/trackHeaderLaneState.ts +9 -2
- package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
- package/src/player/components/trackHeaderLaneValues.ts +54 -13
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
- package/src/player/components/useTimelineClipDrag.ts +0 -3
- package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
- package/src/player/store/batchElementUpdates.test.ts +43 -0
- package/src/player/store/batchElementUpdates.ts +9 -0
- package/src/player/store/elementPatchQueue.ts +35 -0
- package/src/player/store/playerStore.ts +5 -6
- package/src/utils/domEditHelpers.ts +5 -0
- package/src/utils/globalTimeCompiler.test.ts +0 -4
- package/src/utils/globalTimeCompiler.ts +6 -4
- package/src/utils/gsapKeyframeEases.ts +79 -0
- package/src/utils/sameData.ts +9 -0
- package/src/utils/studioFileHistory.ts +1 -0
- package/src/utils/studioPendingEdits.ts +15 -1
- package/dist/chunk-246VB2G3.js.map +0 -1
- package/dist/chunk-3F47EFCT.js.map +0 -1
- package/dist/chunk-5EKWJZLM.js.map +0 -1
- /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
|
@@ -1,12 +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 {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
replaceTweenWithKeyframesInScript,
|
|
6
|
+
syncPositionHoldsBeforeKeyframes,
|
|
7
|
+
updateKeyframeInScript,
|
|
8
|
+
} from "@hyperframes/parsers/gsap-writer-acorn";
|
|
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
|
-
import {
|
|
13
|
+
import { readRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
14
|
+
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
15
|
+
import { toClipKeyframes } from "./gsapShared";
|
|
16
|
+
import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
|
|
17
|
+
import { applyKeyframeAtPlayhead, type EnableKeyframesSession } from "./useEnableKeyframes";
|
|
10
18
|
|
|
11
19
|
/** Runs a composition script as the preview does: a paused timeline, bound, then seeked to `at`. */
|
|
12
20
|
function play(script: string, at: number) {
|
|
@@ -18,14 +26,20 @@ function play(script: string, at: number) {
|
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
/** Drags `#x` to `x` at `at`, writes the plan into the script, and replays the written file there. */
|
|
21
|
-
function dragAndReplay(
|
|
29
|
+
function dragAndReplay(
|
|
30
|
+
script: string,
|
|
31
|
+
x: number,
|
|
32
|
+
at: number,
|
|
33
|
+
selectedPct: number | null = null,
|
|
34
|
+
{ y, backfill }: { y?: number; backfill?: Record<string, number> } = {},
|
|
35
|
+
) {
|
|
22
36
|
const box = document.body.appendChild(document.createElement("div"));
|
|
23
37
|
box.id = "x";
|
|
24
38
|
const { timeline, iframe } = play(script, at);
|
|
25
|
-
usePlayerStore.setState({ currentTime: at, activeKeyframePct:
|
|
39
|
+
usePlayerStore.setState({ currentTime: at, activeKeyframePct: selectedPct });
|
|
26
40
|
const anim = parseGsapScriptAcorn(script).animations[0]!;
|
|
27
41
|
const selection = { id: "x", selector: "#x", element: box } as DomEditSelection;
|
|
28
|
-
const plan = planValueEdit(selection, anim, { x }, iframe);
|
|
42
|
+
const plan = planValueEdit(selection, anim, { x, ...(y != null && { y }) }, iframe, { backfill });
|
|
29
43
|
timeline.kill();
|
|
30
44
|
if (!plan.ok) return { plan };
|
|
31
45
|
const written = replaceTweenWithKeyframesInScript(script, anim.id, plan.mutation)!;
|
|
@@ -54,6 +68,64 @@ it("writes a delayed linear tween so GSAP shows the new value at the playhead, n
|
|
|
54
68
|
expect(shown).toBe(200);
|
|
55
69
|
});
|
|
56
70
|
|
|
71
|
+
it("reads plain array nodes where the lane puts them, though GSAP fills in step durations", () => {
|
|
72
|
+
const source = script("keyframes: [{ x: 60 }, { x: 120 }, { x: 180 }], duration: 3");
|
|
73
|
+
document.body.appendChild(document.createElement("div")).id = "x";
|
|
74
|
+
const { timeline, iframe } = play(source, 0);
|
|
75
|
+
const frame = { ...iframe, contentDocument: document } as HTMLIFrameElement;
|
|
76
|
+
const read = readRuntimeKeyframes(frame, "#x");
|
|
77
|
+
timeline.kill();
|
|
78
|
+
const parsed = parseGsapScriptAcorn(source).animations[0]!.keyframes!.keyframes;
|
|
79
|
+
expect(read?.keyframes.map((kf) => kf.percentage)).toEqual(parsed.map((kf) => kf.percentage));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Three default 0.5 s steps stretched over 3 s: GSAP reaches the middle one at 2 s, where the parse
|
|
83
|
+
// places it at 66.7%.
|
|
84
|
+
it.each([
|
|
85
|
+
["at the playhead, timing array steps on their own timeline", undefined],
|
|
86
|
+
["selected in the lane, in place", 66.7],
|
|
87
|
+
])("changes the middle array keyframe %s", (_, selectedPct) => {
|
|
88
|
+
const { plan, shown } = dragAndReplay(
|
|
89
|
+
script("keyframes: [{ x: 60 }, { x: 120 }, { x: 180 }], duration: 3"),
|
|
90
|
+
130,
|
|
91
|
+
2,
|
|
92
|
+
selectedPct,
|
|
93
|
+
);
|
|
94
|
+
expect(plan.ok && plan.mutation.keyframes.map((kf) => kf.properties.x)).toEqual([60, 130, 180]);
|
|
95
|
+
expect(shown).toBeCloseTo(130, 2);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("keeps an array step that leaves a channel out holding it, as GSAP played it", () => {
|
|
99
|
+
const hold = "keyframes: [{ x: 60, duration: 1 }, { duration: 1 }, { x: 180, duration: 1 }]";
|
|
100
|
+
const { written } = dragAndReplay(script(hold), 97, 1);
|
|
101
|
+
const replay = play(written!, 2);
|
|
102
|
+
expect(gsap.getProperty("#x", "x")).toBe(97);
|
|
103
|
+
replay.timeline.kill();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("holds a newly animated channel at rest through a step list's opening pause", () => {
|
|
107
|
+
const pauseFirst =
|
|
108
|
+
"keyframes: [{ duration: 1 }, { x: 60, duration: 1 }, { x: 180, duration: 1 }]";
|
|
109
|
+
const { written } = dragAndReplay(script(pauseFirst), 37, 2, null, { y: 11, backfill: { y: 0 } });
|
|
110
|
+
for (const [t, y] of [
|
|
111
|
+
[0.5, 0],
|
|
112
|
+
[2, 11],
|
|
113
|
+
]) {
|
|
114
|
+
gsap.set("#x", { clearProps: "all" }); // a reloaded preview starts the tween from rest
|
|
115
|
+
const replay = play(written!, t!);
|
|
116
|
+
expect(gsap.getProperty("#x", "y")).toBeCloseTo(y!, 2);
|
|
117
|
+
replay.timeline.kill();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("holds a channel at its start until the array step that first animates it", () => {
|
|
122
|
+
const late = "keyframes: [{ x: 60 }, { x: 120, y: 50 }], duration: 2";
|
|
123
|
+
const { written } = dragAndReplay(script(late), 200, 2);
|
|
124
|
+
const replay = play(written!, 1);
|
|
125
|
+
expect(gsap.getProperty("#x", "y")).toBe(0);
|
|
126
|
+
replay.timeline.kill();
|
|
127
|
+
});
|
|
128
|
+
|
|
57
129
|
it("keeps GSAP's default ease, by name, for a tween that authors none", () => {
|
|
58
130
|
const { plan } = dragAndReplay(script("duration: 1, x: 100"), 60, 0.5);
|
|
59
131
|
expect(plan.ok && plan.mutation.easeEach).toBe("power1.out");
|
|
@@ -177,3 +249,123 @@ it("writes an edit into a looping tween and keeps its repeat and yoyo, as main d
|
|
|
177
249
|
expect(plan.ok).toBe(true);
|
|
178
250
|
expect(written).toMatch(/repeat: 1[\s\S]*yoyo: true/);
|
|
179
251
|
});
|
|
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
|
+
|
|
337
|
+
it("leaves a step list with a runBackwards step to the runtime: a rewrite would play it differently", async () => {
|
|
338
|
+
const flagged = script(
|
|
339
|
+
"keyframes: [{ x: 60, duration: 1, runBackwards: true }, { x: 180, duration: 1 }], ease: 'none'",
|
|
340
|
+
);
|
|
341
|
+
const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
|
|
342
|
+
const xAt = (src: string, t: number) => {
|
|
343
|
+
gsap.set(box, { clearProps: "all" });
|
|
344
|
+
const replay = play(src, t);
|
|
345
|
+
const x = gsap.getProperty(box, "x");
|
|
346
|
+
replay.timeline.kill();
|
|
347
|
+
return x;
|
|
348
|
+
};
|
|
349
|
+
// The flag plays the first step from 60 back to the start, so a list without it is another animation.
|
|
350
|
+
expect(xAt(flagged, 0.25)).not.toBe(xAt(flagged.replace(", runBackwards: true", ""), 0.25));
|
|
351
|
+
const { timeline, iframe } = play(flagged, 1.5);
|
|
352
|
+
usePlayerStore.setState({ currentTime: 1.5, activeKeyframePct: null });
|
|
353
|
+
const anim = parseGsapScriptAcorn(flagged).animations[0]!;
|
|
354
|
+
const commitMutation = vi.fn(async () => {});
|
|
355
|
+
const selection = { id: "x", selector: "#x", element: box } as DomEditSelection;
|
|
356
|
+
|
|
357
|
+
const edit = commitValueAtPlayhead(
|
|
358
|
+
selection,
|
|
359
|
+
anim,
|
|
360
|
+
{ x: 90 },
|
|
361
|
+
iframe,
|
|
362
|
+
{ commitMutation },
|
|
363
|
+
{
|
|
364
|
+
label: "Move",
|
|
365
|
+
},
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
await expect(edit).rejects.toBeInstanceOf(GsapEditBlockedError);
|
|
369
|
+
timeline.kill();
|
|
370
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
371
|
+
});
|
|
@@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
3
3
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
5
5
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
|
-
import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome";
|
|
7
|
-
import { liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
|
|
6
|
+
import { GSAP_EDIT_BLOCK_COPY, GsapEditBlockedError } from "./gsapEditOutcome";
|
|
7
|
+
import { boxSteps, liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
|
|
8
8
|
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
9
9
|
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
|
|
10
10
|
import { planValueAtPlayhead, planValueEdit, type PlayheadEdit } from "./gsapValueAtPlayhead";
|
|
@@ -166,31 +166,12 @@ describe("a move on a GSAP-animated layer, at the playhead", () => {
|
|
|
166
166
|
});
|
|
167
167
|
|
|
168
168
|
it("adds a keyframe between two steps of a keyframes array", async () => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
keyframes: {
|
|
176
|
-
format: "object-array",
|
|
177
|
-
keyframes: [
|
|
178
|
-
// The parse rounds the step to 66.7%; GSAP times it at 2 s of 3.
|
|
179
|
-
{ percentage: 66.7, properties: { x: 60 } },
|
|
180
|
-
{ percentage: 100, properties: { x: 120 } },
|
|
181
|
-
],
|
|
182
|
-
},
|
|
183
|
-
});
|
|
184
|
-
const steps = [
|
|
185
|
-
{ startTime: () => 0, duration: () => 2 },
|
|
186
|
-
{ startTime: () => 2, duration: () => 1 },
|
|
187
|
-
];
|
|
188
|
-
const live = liveTween(
|
|
189
|
-
el,
|
|
190
|
-
{ start: 0, duration: 3, vars: { keyframes: [] } },
|
|
191
|
-
{ parts: steps },
|
|
192
|
-
);
|
|
193
|
-
const { mutations } = await drag([keys], [live], { time: 1, base: [30, 0], dx: 10 });
|
|
169
|
+
// The parse rounds the first step to 66.7%; GSAP times it at 2 s of 3.
|
|
170
|
+
const { keys, live } = boxSteps([
|
|
171
|
+
[2, { x: 60 }],
|
|
172
|
+
[1, { x: 120 }],
|
|
173
|
+
]);
|
|
174
|
+
const { mutations } = await drag([keys], [live(el)], { time: 1, base: [30, 0], dx: 10 });
|
|
194
175
|
expect(mutations.map((m) => m.type)).toEqual(["replace-with-keyframes"]);
|
|
195
176
|
// An array step eases linearly unless it says otherwise; percentage keyframes would not.
|
|
196
177
|
expect(mutations[0].keyframes).toEqual([
|
|
@@ -527,6 +508,30 @@ describe("planValueAtPlayhead", () => {
|
|
|
527
508
|
]);
|
|
528
509
|
});
|
|
529
510
|
|
|
511
|
+
it("refuses a step that leaves a channel out when GSAP's start for it is unknown", () => {
|
|
512
|
+
const anim = kf([
|
|
513
|
+
{ percentage: 50, properties: {} },
|
|
514
|
+
{ percentage: 100, properties: { x: 20 } },
|
|
515
|
+
]);
|
|
516
|
+
const steps = { ...anim, keyframes: { ...anim.keyframes!, format: "object-array" as const } };
|
|
517
|
+
expect(plan({ anim: steps, at: { percentage: 100 }, values: { x: 5 } })).toEqual({
|
|
518
|
+
ok: false,
|
|
519
|
+
reason: "implicit-end-unknown",
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("changes the nearer of two keyframes under 1% apart", () => {
|
|
524
|
+
const anim = kf([
|
|
525
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
526
|
+
{ percentage: 50.5, properties: { x: 100 } },
|
|
527
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
528
|
+
]);
|
|
529
|
+
const result = plan({ anim, at: { percentage: 50.5 }, values: { x: 150 } });
|
|
530
|
+
expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([
|
|
531
|
+
100, 150, 100,
|
|
532
|
+
]);
|
|
533
|
+
});
|
|
534
|
+
|
|
530
535
|
describe("keyframes eased as a whole", () => {
|
|
531
536
|
const eased = {
|
|
532
537
|
...kf([
|
|
@@ -563,6 +568,42 @@ describe("planValueAtPlayhead", () => {
|
|
|
563
568
|
});
|
|
564
569
|
});
|
|
565
570
|
|
|
571
|
+
it("refuses an inner node of a motionPath tween, which GSAP runs power1.out", () => {
|
|
572
|
+
const path = kf([
|
|
573
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
574
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
575
|
+
{ percentage: 100, properties: { x: 300 } },
|
|
576
|
+
]);
|
|
577
|
+
const anim = { ...path, keyframes: { ...path.keyframes!, fromMotionPath: true as const } };
|
|
578
|
+
expect(plan({ anim, at: { time: 2 }, values: { x: 5 } })).toEqual({
|
|
579
|
+
ok: false,
|
|
580
|
+
reason: "eased-keyframes",
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("changes an inner keyframe picked by its percentage, which the ease does not move", () => {
|
|
585
|
+
const anim = {
|
|
586
|
+
...kf([
|
|
587
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
588
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
589
|
+
{ percentage: 100, properties: { x: 300 } },
|
|
590
|
+
]),
|
|
591
|
+
ease: "power2.out",
|
|
592
|
+
};
|
|
593
|
+
const result = plan({ anim, at: { percentage: 50 }, values: { x: 5 } });
|
|
594
|
+
expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([
|
|
595
|
+
0, 5, 300,
|
|
596
|
+
]);
|
|
597
|
+
expect(result.ok && result.mutation.ease).toBe("power2.out");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("refuses to add a keyframe at a percentage, whose time the ease would move", () => {
|
|
601
|
+
expect(plan({ anim: eased, at: { percentage: 40 }, values: { x: 5 } })).toEqual({
|
|
602
|
+
ok: false,
|
|
603
|
+
reason: "eased-keyframes",
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
566
607
|
it("refuses to add a keyframe, whose time the ease would move", () => {
|
|
567
608
|
expect(plan({ anim: eased, at: { time: 2 }, values: { x: 5 } })).toEqual({
|
|
568
609
|
ok: false,
|
|
@@ -593,3 +634,77 @@ it("refuses a tween whose selector also animates a sibling, before it plans anyt
|
|
|
593
634
|
});
|
|
594
635
|
sibling.remove();
|
|
595
636
|
});
|
|
637
|
+
|
|
638
|
+
describe("a keyframes array the percentage rewrite would change", () => {
|
|
639
|
+
const steps = (
|
|
640
|
+
properties: Array<Record<string, number | string>>,
|
|
641
|
+
edit: Partial<PlayheadEdit> = {},
|
|
642
|
+
) =>
|
|
643
|
+
planValueAtPlayhead({
|
|
644
|
+
anim: tween({
|
|
645
|
+
method: "to",
|
|
646
|
+
properties: {},
|
|
647
|
+
resolvedStart: 0,
|
|
648
|
+
duration: 3,
|
|
649
|
+
keyframes: {
|
|
650
|
+
format: "object-array",
|
|
651
|
+
keyframes: properties.map((p, i) => ({ percentage: ((i + 1) / 3) * 100, properties: p })),
|
|
652
|
+
},
|
|
653
|
+
}),
|
|
654
|
+
at: { percentage: 100 },
|
|
655
|
+
values: { x: 50 },
|
|
656
|
+
implicitEndValue: () => 0,
|
|
657
|
+
...edit,
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it.each([
|
|
661
|
+
["a step delay", { x: 100, delay: 0.5 }, "array-step-delay", "its own delay"],
|
|
662
|
+
["a step callback", { x: 100, onComplete: "__raw:done" }, "array-step-callback", "runs code"],
|
|
663
|
+
[
|
|
664
|
+
"an unlisted callback",
|
|
665
|
+
{ x: 100, onInterrupt: "__raw:stop" },
|
|
666
|
+
"array-step-callback",
|
|
667
|
+
"runs code",
|
|
668
|
+
],
|
|
669
|
+
[
|
|
670
|
+
"a step repeat",
|
|
671
|
+
{ x: 100, repeat: 2 },
|
|
672
|
+
"array-step-config",
|
|
673
|
+
"tween setting (like repeat or stagger)",
|
|
674
|
+
],
|
|
675
|
+
["a computed value", { x: "__raw:offset()" }, "array-step-computed", "comes from code"],
|
|
676
|
+
["a relative value", { x: "-=40" }, "array-step-relative", '"+=40"'],
|
|
677
|
+
["a random value", { x: "random(0, 300)" }, "array-step-random", "random()"],
|
|
678
|
+
[
|
|
679
|
+
"a random value inside a string",
|
|
680
|
+
{ filter: "blur(random(1, 9)px)" },
|
|
681
|
+
"array-step-random",
|
|
682
|
+
"random()",
|
|
683
|
+
],
|
|
684
|
+
] as const)("refuses %s and says so", (_, step, reason, says) => {
|
|
685
|
+
expect(steps([{ x: 60 }, step, { x: 180 }])).toEqual({ ok: false, reason });
|
|
686
|
+
const message = new GsapEditBlockedError("keyframes-uneditable", reason).message;
|
|
687
|
+
expect(message).toContain(says);
|
|
688
|
+
expect(message).toContain("Code tab");
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("rewrites absolute values, colours included", () => {
|
|
692
|
+
const result = steps([{ x: 60, color: "#fff" }, { x: 120, color: "rgb(0, 0, 0)" }, { x: 180 }]);
|
|
693
|
+
expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([
|
|
694
|
+
60, 120, 50,
|
|
695
|
+
]);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("holds a pause step at the dropped value of a channel the drop starts animating", () => {
|
|
699
|
+
const result = steps([{ x: 60 }, {}, { x: 180 }], {
|
|
700
|
+
at: { percentage: 100 / 3 },
|
|
701
|
+
values: { x: 37, y: 11 },
|
|
702
|
+
backfill: { y: 0 },
|
|
703
|
+
});
|
|
704
|
+
expect(result.ok && result.mutation.keyframes.map((k) => k.properties)).toEqual([
|
|
705
|
+
{ x: 37, y: 11 },
|
|
706
|
+
{ x: 37, y: 11 },
|
|
707
|
+
{ x: 180, y: 0 },
|
|
708
|
+
]);
|
|
709
|
+
});
|
|
710
|
+
});
|
|
@@ -2,14 +2,18 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
3
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
4
|
import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeCompiler";
|
|
5
|
+
import { ARRAY_STEP_EASE, PERCENTAGE_SEGMENT_EASE, runEaseOf } from "../utils/gsapKeyframeEases";
|
|
5
6
|
import { roundTo3 } from "../utils/rounding";
|
|
6
7
|
import {
|
|
7
8
|
materializeIfDynamic,
|
|
9
|
+
stepListBlock,
|
|
8
10
|
parkPlayheadOnKeyframe,
|
|
9
11
|
type GsapDragCommitCallbacks,
|
|
10
12
|
} from "./gsapDragCommit";
|
|
11
13
|
import type { GsapEditOutcome, PlayheadEditRefusal } from "./gsapEditOutcome";
|
|
12
14
|
import {
|
|
15
|
+
exactKeyframePct,
|
|
16
|
+
nearestKeyframeIndex,
|
|
13
17
|
findParsedTween,
|
|
14
18
|
parsedImplicitEndValue,
|
|
15
19
|
parsedTweenEase,
|
|
@@ -63,10 +67,8 @@ export type PlayheadEditPlan =
|
|
|
63
67
|
}
|
|
64
68
|
| { ok: false; reason: PlayheadEditRefusal };
|
|
65
69
|
|
|
66
|
-
// GSAP 3.14 defaults: a percentage keyframe eases its segment power1.inOut; an array step is linear.
|
|
67
|
-
const PERCENTAGE_SEGMENT_EASE = "power1.inOut";
|
|
68
|
-
const ARRAY_STEP_EASE = "none";
|
|
69
70
|
const roundPct = (pct: number) => Math.round(pct * 1000) / 1000;
|
|
71
|
+
|
|
70
72
|
const isLinear = (ease: string | undefined) => !ease || ease === "none" || ease === "linear";
|
|
71
73
|
|
|
72
74
|
interface Normalized {
|
|
@@ -86,8 +88,10 @@ function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefus
|
|
|
86
88
|
if (data) {
|
|
87
89
|
if (data.format === "simple-array") return { reason: "simple-array-keyframes" };
|
|
88
90
|
const arrayStep = data.format === "object-array";
|
|
91
|
+
const blocked = stepListBlock(anim);
|
|
92
|
+
if (blocked) return { reason: blocked };
|
|
89
93
|
return {
|
|
90
|
-
runEase:
|
|
94
|
+
runEase: runEaseOf(anim),
|
|
91
95
|
keyframes: data.keyframes.map((kf) => ({
|
|
92
96
|
...kf,
|
|
93
97
|
properties: { ...kf.properties },
|
|
@@ -129,7 +133,7 @@ function valueAt(keyframes: Keyframe[], prop: string, side: "first" | "last") {
|
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
const keyframeAt = (keyframes: Keyframe[], percentage: number) =>
|
|
132
|
-
keyframes
|
|
136
|
+
keyframes[nearestKeyframeIndex(keyframes, percentage)];
|
|
133
137
|
|
|
134
138
|
function upsert(keyframes: Keyframe[], percentage: number, properties: Props, ease?: string) {
|
|
135
139
|
const hit = keyframeAt(keyframes, percentage);
|
|
@@ -164,6 +168,27 @@ function heldEnds(
|
|
|
164
168
|
return held;
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
/** An array step without a channel holds it, where a percentage keyframe would tween through it. */
|
|
172
|
+
function holdOmittedChannels(
|
|
173
|
+
steps: Keyframe[],
|
|
174
|
+
implicit: ImplicitEndValue,
|
|
175
|
+
backfilled: Record<string, number>,
|
|
176
|
+
): boolean {
|
|
177
|
+
const props = new Set(steps.flatMap((kf) => Object.keys(kf.properties)));
|
|
178
|
+
for (const prop of props) {
|
|
179
|
+
let held: number | string | null | undefined;
|
|
180
|
+
for (const kf of steps) {
|
|
181
|
+
if (kf.properties[prop] != null) held = kf.properties[prop];
|
|
182
|
+
else {
|
|
183
|
+
held ??= backfilled[prop] ?? implicit(prop, "start");
|
|
184
|
+
if (held == null) return false;
|
|
185
|
+
kf.properties[prop] = held;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
167
192
|
/** On a keyframe, change it; between two, add one at the playhead; outside the tween, add one there
|
|
168
193
|
* and keep the authored ends. Values come from the file's tween or GSAP's parse, never the DOM. */
|
|
169
194
|
// fallow-ignore-next-line complexity
|
|
@@ -176,6 +201,7 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
|
|
|
176
201
|
if ("reason" in norm) return refuse(norm.reason);
|
|
177
202
|
const keyframes = norm.keyframes;
|
|
178
203
|
const authored = keyframes.length;
|
|
204
|
+
const steps = anim.keyframes?.format === "object-array" ? [...keyframes] : [];
|
|
179
205
|
// GSAP runs `scale` beside scaleX/scaleY and the longhands win, so a per-axis edit splits it.
|
|
180
206
|
if ("scaleX" in values || "scaleY" in values) {
|
|
181
207
|
for (const kf of keyframes) {
|
|
@@ -184,11 +210,14 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
|
|
|
184
210
|
}
|
|
185
211
|
}
|
|
186
212
|
const backfilled: Record<string, number> = {};
|
|
213
|
+
const pauseStepsHoldThePrevious = new Set(
|
|
214
|
+
steps.filter((kf) => Object.keys(kf.properties).length === 0),
|
|
215
|
+
);
|
|
187
216
|
for (const [prop, value] of Object.entries(edit.backfill ?? {})) {
|
|
188
217
|
if (!(prop in values) || keyframes.some((kf) => kf.properties[prop] != null)) continue;
|
|
189
218
|
if (edit.holdFromStart && !keyframes.some((kf) => kf.percentage <= 0))
|
|
190
219
|
keyframes.unshift({ percentage: 0, properties: {} });
|
|
191
|
-
for (const kf of keyframes) kf.properties[prop] = value;
|
|
220
|
+
for (const kf of keyframes) if (!pauseStepsHoldThePrevious.has(kf)) kf.properties[prop] = value;
|
|
192
221
|
backfilled[prop] = value;
|
|
193
222
|
}
|
|
194
223
|
if (Object.keys(values).some((prop) => !keyframes.some((kf) => kf.properties[prop] != null)))
|
|
@@ -204,7 +233,9 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
|
|
|
204
233
|
if (pct >= -KEYFRAME_PCT_MATCH && pct <= 100 + KEYFRAME_PCT_MATCH) {
|
|
205
234
|
const at = Math.min(100, Math.max(0, pct));
|
|
206
235
|
const hit = keyframeAt(keyframes, at);
|
|
207
|
-
|
|
236
|
+
// The ease moves where a time lands, not a keyframe's own percentage or the tween's ends.
|
|
237
|
+
const onKeyframe = "percentage" in edit.at || hit?.percentage === 0 || hit?.percentage === 100;
|
|
238
|
+
if (eased && !(hit && onKeyframe)) return refuse("eased-keyframes");
|
|
208
239
|
const next = [...keyframes]
|
|
209
240
|
.sort((a, b) => a.percentage - b.percentage)
|
|
210
241
|
.find((kf) => kf.percentage > at + KEYFRAME_PCT_MATCH);
|
|
@@ -231,6 +262,8 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
|
|
|
231
262
|
upsert(keyframes, before ? 0 : 100, { ...held, ...values }, before ? undefined : linear);
|
|
232
263
|
}
|
|
233
264
|
|
|
265
|
+
if (!holdOmittedChannels(steps, edit.implicitEndValue, backfilled))
|
|
266
|
+
return refuse("implicit-end-unknown");
|
|
234
267
|
keyframes.sort((a, b) => a.percentage - b.percentage);
|
|
235
268
|
return {
|
|
236
269
|
ok: true,
|
|
@@ -263,7 +296,10 @@ export function planValueEdit(
|
|
|
263
296
|
const timed = withExactStepTimes(anim, tween);
|
|
264
297
|
return planValueAtPlayhead({
|
|
265
298
|
anim: withLiveTiming(timed, tween),
|
|
266
|
-
at:
|
|
299
|
+
at:
|
|
300
|
+
activeKeyframePct != null
|
|
301
|
+
? { percentage: exactKeyframePct(anim, tween, activeKeyframePct) }
|
|
302
|
+
: { time: currentTime },
|
|
267
303
|
values,
|
|
268
304
|
backfill,
|
|
269
305
|
holdFromStart,
|
|
@@ -286,6 +322,9 @@ export async function commitValueAtPlayhead(
|
|
|
286
322
|
): Promise<GsapEditOutcome> {
|
|
287
323
|
await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
288
324
|
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
325
|
+
const tween = findParsedTween(iframe, selection.element, anim);
|
|
326
|
+
const parkAt =
|
|
327
|
+
activeKeyframePct == null ? null : exactKeyframePct(anim, tween, activeKeyframePct);
|
|
289
328
|
const plan = planValueEdit(selection, anim, values, iframe, options);
|
|
290
329
|
if (!plan.ok) return { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason };
|
|
291
330
|
await callbacks.commitMutation(selection, plan.mutation, {
|
|
@@ -294,9 +333,9 @@ export async function commitValueAtPlayhead(
|
|
|
294
333
|
beforeReload: options.beforeReload,
|
|
295
334
|
...(plan.added && { keyframeAction: "add" as const }),
|
|
296
335
|
});
|
|
297
|
-
if (
|
|
336
|
+
if (parkAt != null) {
|
|
298
337
|
setActiveKeyframePct(null);
|
|
299
|
-
parkPlayheadOnKeyframe(anim,
|
|
338
|
+
parkPlayheadOnKeyframe(anim, parkAt);
|
|
300
339
|
}
|
|
301
340
|
return { status: "persisted" };
|
|
302
341
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
|
-
import type
|
|
4
|
+
import { commitWholePathOffset, type GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
5
5
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
6
6
|
|
|
7
7
|
// Regression (#1808): with auto-keyframe recording off, a manual edit on an
|
|
@@ -203,3 +203,74 @@ describe("commitWholePropertyOffset", () => {
|
|
|
203
203
|
expect(mutations[0]).toMatchObject({ easeEach: "none" });
|
|
204
204
|
});
|
|
205
205
|
});
|
|
206
|
+
|
|
207
|
+
it.each([
|
|
208
|
+
[{ rotation: "+=40" }, "array-step-relative"],
|
|
209
|
+
[{ rotation: 40, onComplete: "__raw:done" }, "array-step-callback"],
|
|
210
|
+
[{ rotation: "random(0, 90)" }, "array-step-random"],
|
|
211
|
+
])("refuses to rewrite a step list holding %o and writes nothing", async (last, detail) => {
|
|
212
|
+
const steps = [{ rotation: 10 }, last].map((properties, i) => ({
|
|
213
|
+
percentage: (i + 1) * 50,
|
|
214
|
+
properties,
|
|
215
|
+
}));
|
|
216
|
+
const anim = {
|
|
217
|
+
id: "#box-steps",
|
|
218
|
+
targetSelector: "#box",
|
|
219
|
+
method: "to",
|
|
220
|
+
resolvedStart: 0,
|
|
221
|
+
duration: 2,
|
|
222
|
+
keyframes: { format: "object-array", keyframes: steps },
|
|
223
|
+
} as unknown as GsapAnimation;
|
|
224
|
+
const { mutations, callbacks } = recordingCallbacks();
|
|
225
|
+
const rotate = commitWholePropertyOffset(
|
|
226
|
+
selection(),
|
|
227
|
+
anim,
|
|
228
|
+
{ rotation: 50 },
|
|
229
|
+
100,
|
|
230
|
+
null,
|
|
231
|
+
callbacks,
|
|
232
|
+
"Rotate",
|
|
233
|
+
);
|
|
234
|
+
await expect(rotate).rejects.toMatchObject({ reason: "keyframes-uneditable", detail });
|
|
235
|
+
expect(mutations).toEqual([]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("refuses to shift a layer's step list whose step has its own delay, writing nothing", async () => {
|
|
239
|
+
// No drag baseline on the element: the move starts from a zero offset.
|
|
240
|
+
const el = {
|
|
241
|
+
style: { getPropertyValue: () => "", setProperty: () => {} },
|
|
242
|
+
getAttribute: () => null,
|
|
243
|
+
removeAttribute: () => {},
|
|
244
|
+
getBoundingClientRect: () => ({ top: 0, left: 0 }),
|
|
245
|
+
};
|
|
246
|
+
const anim = {
|
|
247
|
+
id: "#box-steps",
|
|
248
|
+
targetSelector: "#box",
|
|
249
|
+
method: "to",
|
|
250
|
+
resolvedStart: 0,
|
|
251
|
+
duration: 2,
|
|
252
|
+
keyframes: {
|
|
253
|
+
format: "object-array",
|
|
254
|
+
keyframes: [
|
|
255
|
+
{ percentage: 50, properties: { x: 100, delay: 0.5 } },
|
|
256
|
+
{ percentage: 100, properties: { x: 200 } },
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
} as unknown as GsapAnimation;
|
|
260
|
+
const { mutations, callbacks } = recordingCallbacks();
|
|
261
|
+
const sel = { ...selection(), element: el } as DomEditSelection;
|
|
262
|
+
const move = commitWholePathOffset(
|
|
263
|
+
sel,
|
|
264
|
+
anim,
|
|
265
|
+
{ x: 40, y: 0 },
|
|
266
|
+
{ x: 0, y: 0 },
|
|
267
|
+
null,
|
|
268
|
+
"#box",
|
|
269
|
+
callbacks,
|
|
270
|
+
);
|
|
271
|
+
await expect(move).rejects.toMatchObject({
|
|
272
|
+
reason: "keyframes-uneditable",
|
|
273
|
+
detail: "array-step-delay",
|
|
274
|
+
});
|
|
275
|
+
expect(mutations).toEqual([]);
|
|
276
|
+
});
|