@hyperframes/studio 0.8.125 → 0.8.127
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-CmIh-wq9.js → hyperframes-player-uqzDaxVL.js} +1 -1
- package/dist/assets/{index-4LRR6o2F.js → index-CLBNYlDY.js} +1 -1
- package/dist/assets/{index-CrYoEZ5p.js → index-COhbL1xq.js} +177 -177
- package/dist/assets/{index-yCK8osBg.js → index-DXSYlqmV.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +5368 -4986
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
- package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
- package/src/components/editor/MotionPathNode.tsx +56 -0
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +167 -33
- package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
- package/src/components/editor/MotionPathOverlay.tsx +49 -86
- package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
- package/src/components/editor/manualEditsSeekReapply.ts +3 -6
- package/src/components/editor/manualOffsetDrag.ts +2 -3
- 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 +61 -1
- package/src/components/editor/motionPathGeometry.ts +50 -10
- package/src/components/editor/motionPathHome.ts +75 -0
- package/src/components/editor/motionPathLayerNode.test.ts +43 -35
- package/src/components/editor/motionPathLayerNode.ts +20 -16
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
- package/src/components/editor/studioMotion.ts +7 -19
- package/src/components/editor/useMotionPathData.test.tsx +236 -0
- package/src/components/editor/useMotionPathData.ts +23 -61
- package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
- package/src/hooks/gsapDragCommit.ts +33 -1
- package/src/hooks/gsapEditOutcome.ts +27 -2
- package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
- package/src/hooks/gsapParsedTween.ts +39 -2
- package/src/hooks/gsapRuntimeBridge.ts +7 -1
- package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +11 -4
- package/src/hooks/gsapShared.test.ts +1 -0
- package/src/hooks/gsapShared.ts +8 -3
- package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +107 -5
- package/src/hooks/gsapValueAtPlayhead.test.ts +129 -27
- package/src/hooks/gsapValueAtPlayhead.ts +46 -6
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
- package/src/hooks/intersectionObserverTestUtils.ts +16 -0
- package/src/hooks/resizeObserverTestUtils.ts +9 -2
- package/src/hooks/timelineEditingHelpers.ts +2 -9
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
- package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
- package/src/hooks/useThumbnailStripSize.ts +206 -17
- package/src/noWindowBoundDomChecks.test.ts +31 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -6
- package/src/player/components/ImageThumbnail.tsx +11 -5
- package/src/player/components/ThumbnailTiles.test.tsx +30 -0
- package/src/player/components/ThumbnailTiles.tsx +41 -0
- package/src/player/components/VideoThumbnail.test.tsx +116 -9
- package/src/player/components/VideoThumbnail.tsx +11 -5
- package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
- package/src/player/lib/timelineGroupInfo.ts +2 -1
- package/src/webmcp/webmcpTestUtils.ts +2 -2
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { gsap } from "gsap";
|
|
3
|
+
import { expect, it } from "vitest";
|
|
4
|
+
import { readRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
5
|
+
|
|
6
|
+
/** The edit bench's `keys` layer: GSAP starts it at x 40, 260 wide, and plays it to 1 s. */
|
|
7
|
+
function read(keyframes: string) {
|
|
8
|
+
const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
|
|
9
|
+
box.style.width = "260px";
|
|
10
|
+
gsap.set(box, { x: 40 });
|
|
11
|
+
const win = { __timelines: {} as Record<string, gsap.core.Timeline> };
|
|
12
|
+
new Function(
|
|
13
|
+
"gsap",
|
|
14
|
+
"window",
|
|
15
|
+
`var tl = gsap.timeline({ paused: true });
|
|
16
|
+
tl.to("#x", { keyframes: ${keyframes} }, 0);
|
|
17
|
+
window.__timelines["t"] = tl;`,
|
|
18
|
+
)(gsap, win);
|
|
19
|
+
const timeline = win.__timelines.t!;
|
|
20
|
+
timeline.progress(0.0001, true).seek(1);
|
|
21
|
+
const iframe = { contentWindow: { ...win, gsap }, contentDocument: document };
|
|
22
|
+
const got = readRuntimeKeyframes(iframe as unknown as HTMLIFrameElement, "#x");
|
|
23
|
+
timeline.kill();
|
|
24
|
+
box.remove();
|
|
25
|
+
return got;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
it("reads where GSAP started a step list whose first keyframe comes later", () => {
|
|
29
|
+
const got = read(
|
|
30
|
+
`[{ x: 60, width: 280, duration: 2, ease: "none" }, { x: 120, width: 320, duration: 1, ease: "none" }]`,
|
|
31
|
+
);
|
|
32
|
+
expect(got?.start).toEqual({ x: 40, width: 260 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each([
|
|
36
|
+
[`{ "50%": { x: 60, y: 20 }, "100%": { x: 120, y: 40 } }, duration: 2`, { x: 40, y: 0 }],
|
|
37
|
+
[
|
|
38
|
+
`{ "50%": { x: 60, width: 280 }, "100%": { x: 120, width: 320 } }, duration: 2`,
|
|
39
|
+
{ x: 40, width: 260 },
|
|
40
|
+
],
|
|
41
|
+
])("reads every channel's start for a percentage list past 0%: %s", (keyframes, start) => {
|
|
42
|
+
expect(read(keyframes)?.start).toEqual(start);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reads no start for keyframes that begin at 0%, or a step list that begins where GSAP started", () => {
|
|
46
|
+
expect(read(`{ "0%": { x: 10 }, "100%": { x: 120 } }, duration: 3`)?.start).toBeUndefined();
|
|
47
|
+
expect(read(`[{ x: 40, duration: 0 }, { x: 120, duration: 1 }]`)?.start).toBeUndefined();
|
|
48
|
+
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
|
|
12
12
|
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
|
|
13
13
|
import { roundTo3 } from "../utils/rounding";
|
|
14
|
+
import { withParsedStart } from "./gsapParsedTween";
|
|
14
15
|
import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
|
|
15
16
|
import { gsapRendersTransform } from "../components/editor/gsapAnimatesProperty";
|
|
16
17
|
|
|
@@ -42,8 +43,14 @@ export interface RuntimeTimeline {
|
|
|
42
43
|
to?: (targets: Element[], vars: GsapVars, position?: number) => RuntimeTween;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
type Pct = { percentage: number; properties: Record<string, number | string
|
|
46
|
-
export type ReadTween = {
|
|
46
|
+
type Pct = { percentage: number; properties: Record<string, number | string>; step?: number };
|
|
47
|
+
export type ReadTween = {
|
|
48
|
+
keyframes: Pct[];
|
|
49
|
+
easeEach?: string;
|
|
50
|
+
arcPath?: ArcPathConfig;
|
|
51
|
+
/** GSAP's start values when the first keyframe is after 0%. */
|
|
52
|
+
start?: Record<string, number>;
|
|
53
|
+
};
|
|
47
54
|
|
|
48
55
|
export interface RuntimeKeyframeEntry {
|
|
49
56
|
keyframes: Pct[];
|
|
@@ -365,11 +372,11 @@ export function readRuntimeKeyframes(
|
|
|
365
372
|
const read = readTween(tween.vars);
|
|
366
373
|
if (!read) continue;
|
|
367
374
|
if (requireChannels && !readCarriesChannel(read, requireChannels)) continue;
|
|
368
|
-
if (firstRead === null) firstRead = read;
|
|
375
|
+
if (firstRead === null) firstRead = withParsedStart(read, tween);
|
|
369
376
|
// Prefer the tween whose [start, start+dur] contains the playhead.
|
|
370
377
|
if (now != null) {
|
|
371
378
|
const start = typeof tween.startTime === "function" ? tween.startTime() : 0;
|
|
372
|
-
if (now >= start - 1e-3 && now <= start + dur + 1e-3) return read;
|
|
379
|
+
if (now >= start - 1e-3 && now <= start + dur + 1e-3) return withParsedStart(read, tween);
|
|
373
380
|
}
|
|
374
381
|
}
|
|
375
382
|
}
|
|
@@ -64,6 +64,7 @@ describe("parsePercentageKeyframes", () => {
|
|
|
64
64
|
{ x: 1480, y: 160 },
|
|
65
65
|
] as unknown as Record<string, unknown>);
|
|
66
66
|
expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 33.3, 66.7, 100]);
|
|
67
|
+
expect(out?.keyframes.map((k) => k.step)).toEqual([0, 1, 2, 3]);
|
|
67
68
|
expect(out?.keyframes[1]!.properties).toEqual({ x: 520, y: 120 });
|
|
68
69
|
});
|
|
69
70
|
|
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Centralises duplicated interfaces, constants, and small utilities
|
|
4
4
|
* to reduce drift risk.
|
|
5
5
|
*/
|
|
6
|
+
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
6
7
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
7
8
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
8
9
|
import {
|
|
@@ -223,7 +224,7 @@ function structuralSelector(element: Element): string | null {
|
|
|
223
224
|
const parts: string[] = [];
|
|
224
225
|
for (let node: Element | null = element; node; node = node.parentElement) {
|
|
225
226
|
if (node !== element) {
|
|
226
|
-
const id = node
|
|
227
|
+
const id = isHtmlElement(node) ? node.id : "";
|
|
227
228
|
const hfId = node.getAttribute("data-hf-id");
|
|
228
229
|
if (id) {
|
|
229
230
|
parts.unshift(idSelector(id));
|
|
@@ -398,7 +399,11 @@ export function queryIframeElement(
|
|
|
398
399
|
// ── Keyframe parsing ──────────────────────────────────────────────────────────
|
|
399
400
|
|
|
400
401
|
export interface ParsedPercentageKeyframes {
|
|
401
|
-
keyframes: Array<{
|
|
402
|
+
keyframes: Array<{
|
|
403
|
+
percentage: number;
|
|
404
|
+
properties: Record<string, number | string>;
|
|
405
|
+
step?: number;
|
|
406
|
+
}>;
|
|
402
407
|
easeEach?: string;
|
|
403
408
|
}
|
|
404
409
|
|
|
@@ -441,7 +446,7 @@ export function parsePercentageKeyframes(
|
|
|
441
446
|
if (!entry || typeof entry !== "object") return;
|
|
442
447
|
const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
|
|
443
448
|
const properties = collectAnimatableKeyframeProperties(entry);
|
|
444
|
-
if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties });
|
|
449
|
+
if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties, step: i });
|
|
445
450
|
});
|
|
446
451
|
return keyframes.length > 0 ? { keyframes } : null;
|
|
447
452
|
}
|
|
@@ -110,6 +110,16 @@ describe("writeTargetSelector", () => {
|
|
|
110
110
|
expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
it("anchors on an ancestor's id in the preview, whichever window built its nodes", () => {
|
|
114
|
+
const frame = document.body.appendChild(document.createElement("iframe"));
|
|
115
|
+
const doc = frame.contentDocument!;
|
|
116
|
+
doc.body.innerHTML = `<div id="scene"><div></div><div></div></div>`;
|
|
117
|
+
const el = doc.querySelectorAll<HTMLElement>("#scene > div")[1]!;
|
|
118
|
+
const selection = { ...stableSelectionFor(el), selector: "div[unclosed" } as DomEditSelection;
|
|
119
|
+
|
|
120
|
+
expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
|
|
121
|
+
});
|
|
122
|
+
|
|
113
123
|
it("returns null when a live DOM is present and no rung addresses one element", () => {
|
|
114
124
|
document.body.innerHTML = `<div id="scene"><div class="group"></div><div class="group"></div></div>`;
|
|
115
125
|
const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
import { gsap } from "gsap";
|
|
3
3
|
import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
|
|
4
4
|
import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
5
|
-
import { afterEach, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
6
6
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
8
|
import { findParsedTween, parsedImplicitEndValue, parsedTweenEase } from "./gsapParsedTween";
|
|
9
|
-
import {
|
|
9
|
+
import { readRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
10
|
+
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
11
|
+
import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
|
|
10
12
|
|
|
11
13
|
/** Runs a composition script as the preview does: a paused timeline, bound, then seeked to `at`. */
|
|
12
14
|
function play(script: string, at: number) {
|
|
@@ -18,14 +20,20 @@ function play(script: string, at: number) {
|
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/** Drags `#x` to `x` at `at`, writes the plan into the script, and replays the written file there. */
|
|
21
|
-
function dragAndReplay(
|
|
23
|
+
function dragAndReplay(
|
|
24
|
+
script: string,
|
|
25
|
+
x: number,
|
|
26
|
+
at: number,
|
|
27
|
+
selectedPct: number | null = null,
|
|
28
|
+
{ y, backfill }: { y?: number; backfill?: Record<string, number> } = {},
|
|
29
|
+
) {
|
|
22
30
|
const box = document.body.appendChild(document.createElement("div"));
|
|
23
31
|
box.id = "x";
|
|
24
32
|
const { timeline, iframe } = play(script, at);
|
|
25
|
-
usePlayerStore.setState({ currentTime: at, activeKeyframePct:
|
|
33
|
+
usePlayerStore.setState({ currentTime: at, activeKeyframePct: selectedPct });
|
|
26
34
|
const anim = parseGsapScriptAcorn(script).animations[0]!;
|
|
27
35
|
const selection = { id: "x", selector: "#x", element: box } as DomEditSelection;
|
|
28
|
-
const plan = planValueEdit(selection, anim, { x }, iframe);
|
|
36
|
+
const plan = planValueEdit(selection, anim, { x, ...(y != null && { y }) }, iframe, { backfill });
|
|
29
37
|
timeline.kill();
|
|
30
38
|
if (!plan.ok) return { plan };
|
|
31
39
|
const written = replaceTweenWithKeyframesInScript(script, anim.id, plan.mutation)!;
|
|
@@ -54,6 +62,64 @@ it("writes a delayed linear tween so GSAP shows the new value at the playhead, n
|
|
|
54
62
|
expect(shown).toBe(200);
|
|
55
63
|
});
|
|
56
64
|
|
|
65
|
+
it("reads plain array nodes where the lane puts them, though GSAP fills in step durations", () => {
|
|
66
|
+
const source = script("keyframes: [{ x: 60 }, { x: 120 }, { x: 180 }], duration: 3");
|
|
67
|
+
document.body.appendChild(document.createElement("div")).id = "x";
|
|
68
|
+
const { timeline, iframe } = play(source, 0);
|
|
69
|
+
const frame = { ...iframe, contentDocument: document } as HTMLIFrameElement;
|
|
70
|
+
const read = readRuntimeKeyframes(frame, "#x");
|
|
71
|
+
timeline.kill();
|
|
72
|
+
const parsed = parseGsapScriptAcorn(source).animations[0]!.keyframes!.keyframes;
|
|
73
|
+
expect(read?.keyframes.map((kf) => kf.percentage)).toEqual(parsed.map((kf) => kf.percentage));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 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 50%.
|
|
78
|
+
it.each([
|
|
79
|
+
["at the playhead, timing array steps on their own timeline", undefined],
|
|
80
|
+
["selected in the lane, in place", 50],
|
|
81
|
+
])("changes the middle array keyframe %s", (_, selectedPct) => {
|
|
82
|
+
const { plan, shown } = dragAndReplay(
|
|
83
|
+
script("keyframes: [{ x: 60 }, { x: 120 }, { x: 180 }], duration: 3"),
|
|
84
|
+
130,
|
|
85
|
+
2,
|
|
86
|
+
selectedPct,
|
|
87
|
+
);
|
|
88
|
+
expect(plan.ok && plan.mutation.keyframes.map((kf) => kf.properties.x)).toEqual([60, 130, 180]);
|
|
89
|
+
expect(shown).toBeCloseTo(130, 2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps an array step that leaves a channel out holding it, as GSAP played it", () => {
|
|
93
|
+
const hold = "keyframes: [{ x: 60, duration: 1 }, { duration: 1 }, { x: 180, duration: 1 }]";
|
|
94
|
+
const { written } = dragAndReplay(script(hold), 97, 1);
|
|
95
|
+
const replay = play(written!, 2);
|
|
96
|
+
expect(gsap.getProperty("#x", "x")).toBe(97);
|
|
97
|
+
replay.timeline.kill();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("holds a newly animated channel at rest through a step list's opening pause", () => {
|
|
101
|
+
const pauseFirst =
|
|
102
|
+
"keyframes: [{ duration: 1 }, { x: 60, duration: 1 }, { x: 180, duration: 1 }]";
|
|
103
|
+
const { written } = dragAndReplay(script(pauseFirst), 37, 2, null, { y: 11, backfill: { y: 0 } });
|
|
104
|
+
for (const [t, y] of [
|
|
105
|
+
[0.5, 0],
|
|
106
|
+
[2, 11],
|
|
107
|
+
]) {
|
|
108
|
+
gsap.set("#x", { clearProps: "all" }); // a reloaded preview starts the tween from rest
|
|
109
|
+
const replay = play(written!, t!);
|
|
110
|
+
expect(gsap.getProperty("#x", "y")).toBeCloseTo(y!, 2);
|
|
111
|
+
replay.timeline.kill();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("holds a channel at its start until the array step that first animates it", () => {
|
|
116
|
+
const late = "keyframes: [{ x: 60 }, { x: 120, y: 50 }], duration: 2";
|
|
117
|
+
const { written } = dragAndReplay(script(late), 200, 2);
|
|
118
|
+
const replay = play(written!, 1);
|
|
119
|
+
expect(gsap.getProperty("#x", "y")).toBe(0);
|
|
120
|
+
replay.timeline.kill();
|
|
121
|
+
});
|
|
122
|
+
|
|
57
123
|
it("keeps GSAP's default ease, by name, for a tween that authors none", () => {
|
|
58
124
|
const { plan } = dragAndReplay(script("duration: 1, x: 100"), 60, 0.5);
|
|
59
125
|
expect(plan.ok && plan.mutation.easeEach).toBe("power1.out");
|
|
@@ -177,3 +243,39 @@ it("writes an edit into a looping tween and keeps its repeat and yoyo, as main d
|
|
|
177
243
|
expect(plan.ok).toBe(true);
|
|
178
244
|
expect(written).toMatch(/repeat: 1[\s\S]*yoyo: true/);
|
|
179
245
|
});
|
|
246
|
+
|
|
247
|
+
it("leaves a step list with a runBackwards step to the runtime: a rewrite would play it differently", async () => {
|
|
248
|
+
const flagged = script(
|
|
249
|
+
"keyframes: [{ x: 60, duration: 1, runBackwards: true }, { x: 180, duration: 1 }], ease: 'none'",
|
|
250
|
+
);
|
|
251
|
+
const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
|
|
252
|
+
const xAt = (src: string, t: number) => {
|
|
253
|
+
gsap.set(box, { clearProps: "all" });
|
|
254
|
+
const replay = play(src, t);
|
|
255
|
+
const x = gsap.getProperty(box, "x");
|
|
256
|
+
replay.timeline.kill();
|
|
257
|
+
return x;
|
|
258
|
+
};
|
|
259
|
+
// The flag plays the first step from 60 back to the start, so a list without it is another animation.
|
|
260
|
+
expect(xAt(flagged, 0.25)).not.toBe(xAt(flagged.replace(", runBackwards: true", ""), 0.25));
|
|
261
|
+
const { timeline, iframe } = play(flagged, 1.5);
|
|
262
|
+
usePlayerStore.setState({ currentTime: 1.5, activeKeyframePct: null });
|
|
263
|
+
const anim = parseGsapScriptAcorn(flagged).animations[0]!;
|
|
264
|
+
const commitMutation = vi.fn(async () => {});
|
|
265
|
+
const selection = { id: "x", selector: "#x", element: box } as DomEditSelection;
|
|
266
|
+
|
|
267
|
+
const edit = commitValueAtPlayhead(
|
|
268
|
+
selection,
|
|
269
|
+
anim,
|
|
270
|
+
{ x: 90 },
|
|
271
|
+
iframe,
|
|
272
|
+
{ commitMutation },
|
|
273
|
+
{
|
|
274
|
+
label: "Move",
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
await expect(edit).rejects.toBeInstanceOf(GsapEditBlockedError);
|
|
279
|
+
timeline.kill();
|
|
280
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
281
|
+
});
|
|
@@ -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,29 @@ describe("planValueAtPlayhead", () => {
|
|
|
563
568
|
});
|
|
564
569
|
});
|
|
565
570
|
|
|
571
|
+
it("changes an inner keyframe picked by its percentage, which the ease does not move", () => {
|
|
572
|
+
const anim = {
|
|
573
|
+
...kf([
|
|
574
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
575
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
576
|
+
{ percentage: 100, properties: { x: 300 } },
|
|
577
|
+
]),
|
|
578
|
+
ease: "power2.out",
|
|
579
|
+
};
|
|
580
|
+
const result = plan({ anim, at: { percentage: 50 }, values: { x: 5 } });
|
|
581
|
+
expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([
|
|
582
|
+
0, 5, 300,
|
|
583
|
+
]);
|
|
584
|
+
expect(result.ok && result.mutation.ease).toBe("power2.out");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("refuses to add a keyframe at a percentage, whose time the ease would move", () => {
|
|
588
|
+
expect(plan({ anim: eased, at: { percentage: 40 }, values: { x: 5 } })).toEqual({
|
|
589
|
+
ok: false,
|
|
590
|
+
reason: "eased-keyframes",
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
566
594
|
it("refuses to add a keyframe, whose time the ease would move", () => {
|
|
567
595
|
expect(plan({ anim: eased, at: { time: 2 }, values: { x: 5 } })).toEqual({
|
|
568
596
|
ok: false,
|
|
@@ -593,3 +621,77 @@ it("refuses a tween whose selector also animates a sibling, before it plans anyt
|
|
|
593
621
|
});
|
|
594
622
|
sibling.remove();
|
|
595
623
|
});
|
|
624
|
+
|
|
625
|
+
describe("a keyframes array the percentage rewrite would change", () => {
|
|
626
|
+
const steps = (
|
|
627
|
+
properties: Array<Record<string, number | string>>,
|
|
628
|
+
edit: Partial<PlayheadEdit> = {},
|
|
629
|
+
) =>
|
|
630
|
+
planValueAtPlayhead({
|
|
631
|
+
anim: tween({
|
|
632
|
+
method: "to",
|
|
633
|
+
properties: {},
|
|
634
|
+
resolvedStart: 0,
|
|
635
|
+
duration: 3,
|
|
636
|
+
keyframes: {
|
|
637
|
+
format: "object-array",
|
|
638
|
+
keyframes: properties.map((p, i) => ({ percentage: ((i + 1) / 3) * 100, properties: p })),
|
|
639
|
+
},
|
|
640
|
+
}),
|
|
641
|
+
at: { percentage: 100 },
|
|
642
|
+
values: { x: 50 },
|
|
643
|
+
implicitEndValue: () => 0,
|
|
644
|
+
...edit,
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it.each([
|
|
648
|
+
["a step delay", { x: 100, delay: 0.5 }, "array-step-delay", "its own delay"],
|
|
649
|
+
["a step callback", { x: 100, onComplete: "__raw:done" }, "array-step-callback", "runs code"],
|
|
650
|
+
[
|
|
651
|
+
"an unlisted callback",
|
|
652
|
+
{ x: 100, onInterrupt: "__raw:stop" },
|
|
653
|
+
"array-step-callback",
|
|
654
|
+
"runs code",
|
|
655
|
+
],
|
|
656
|
+
[
|
|
657
|
+
"a step repeat",
|
|
658
|
+
{ x: 100, repeat: 2 },
|
|
659
|
+
"array-step-config",
|
|
660
|
+
"tween setting (like repeat or stagger)",
|
|
661
|
+
],
|
|
662
|
+
["a computed value", { x: "__raw:offset()" }, "array-step-computed", "comes from code"],
|
|
663
|
+
["a relative value", { x: "-=40" }, "array-step-relative", '"+=40"'],
|
|
664
|
+
["a random value", { x: "random(0, 300)" }, "array-step-random", "random()"],
|
|
665
|
+
[
|
|
666
|
+
"a random value inside a string",
|
|
667
|
+
{ filter: "blur(random(1, 9)px)" },
|
|
668
|
+
"array-step-random",
|
|
669
|
+
"random()",
|
|
670
|
+
],
|
|
671
|
+
] as const)("refuses %s and says so", (_, step, reason, says) => {
|
|
672
|
+
expect(steps([{ x: 60 }, step, { x: 180 }])).toEqual({ ok: false, reason });
|
|
673
|
+
const message = new GsapEditBlockedError("keyframes-uneditable", reason).message;
|
|
674
|
+
expect(message).toContain(says);
|
|
675
|
+
expect(message).toContain("Code tab");
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("rewrites absolute values, colours included", () => {
|
|
679
|
+
const result = steps([{ x: 60, color: "#fff" }, { x: 120, color: "rgb(0, 0, 0)" }, { x: 180 }]);
|
|
680
|
+
expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([
|
|
681
|
+
60, 120, 50,
|
|
682
|
+
]);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("holds a pause step at the dropped value of a channel the drop starts animating", () => {
|
|
686
|
+
const result = steps([{ x: 60 }, {}, { x: 180 }], {
|
|
687
|
+
at: { percentage: 100 / 3 },
|
|
688
|
+
values: { x: 37, y: 11 },
|
|
689
|
+
backfill: { y: 0 },
|
|
690
|
+
});
|
|
691
|
+
expect(result.ok && result.mutation.keyframes.map((k) => k.properties)).toEqual([
|
|
692
|
+
{ x: 37, y: 11 },
|
|
693
|
+
{ x: 37, y: 11 },
|
|
694
|
+
{ x: 180, y: 0 },
|
|
695
|
+
]);
|
|
696
|
+
});
|
|
697
|
+
});
|
|
@@ -5,11 +5,14 @@ import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeComp
|
|
|
5
5
|
import { roundTo3 } from "../utils/rounding";
|
|
6
6
|
import {
|
|
7
7
|
materializeIfDynamic,
|
|
8
|
+
stepListBlock,
|
|
8
9
|
parkPlayheadOnKeyframe,
|
|
9
10
|
type GsapDragCommitCallbacks,
|
|
10
11
|
} from "./gsapDragCommit";
|
|
11
12
|
import type { GsapEditOutcome, PlayheadEditRefusal } from "./gsapEditOutcome";
|
|
12
13
|
import {
|
|
14
|
+
exactKeyframePct,
|
|
15
|
+
nearestKeyframeIndex,
|
|
13
16
|
findParsedTween,
|
|
14
17
|
parsedImplicitEndValue,
|
|
15
18
|
parsedTweenEase,
|
|
@@ -86,6 +89,8 @@ function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefus
|
|
|
86
89
|
if (data) {
|
|
87
90
|
if (data.format === "simple-array") return { reason: "simple-array-keyframes" };
|
|
88
91
|
const arrayStep = data.format === "object-array";
|
|
92
|
+
const blocked = stepListBlock(anim);
|
|
93
|
+
if (blocked) return { reason: blocked };
|
|
89
94
|
return {
|
|
90
95
|
runEase: data.ease ?? anim.ease,
|
|
91
96
|
keyframes: data.keyframes.map((kf) => ({
|
|
@@ -129,7 +134,7 @@ function valueAt(keyframes: Keyframe[], prop: string, side: "first" | "last") {
|
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
const keyframeAt = (keyframes: Keyframe[], percentage: number) =>
|
|
132
|
-
keyframes
|
|
137
|
+
keyframes[nearestKeyframeIndex(keyframes, percentage)];
|
|
133
138
|
|
|
134
139
|
function upsert(keyframes: Keyframe[], percentage: number, properties: Props, ease?: string) {
|
|
135
140
|
const hit = keyframeAt(keyframes, percentage);
|
|
@@ -164,6 +169,27 @@ function heldEnds(
|
|
|
164
169
|
return held;
|
|
165
170
|
}
|
|
166
171
|
|
|
172
|
+
/** An array step without a channel holds it, where a percentage keyframe would tween through it. */
|
|
173
|
+
function holdOmittedChannels(
|
|
174
|
+
steps: Keyframe[],
|
|
175
|
+
implicit: ImplicitEndValue,
|
|
176
|
+
backfilled: Record<string, number>,
|
|
177
|
+
): boolean {
|
|
178
|
+
const props = new Set(steps.flatMap((kf) => Object.keys(kf.properties)));
|
|
179
|
+
for (const prop of props) {
|
|
180
|
+
let held: number | string | null | undefined;
|
|
181
|
+
for (const kf of steps) {
|
|
182
|
+
if (kf.properties[prop] != null) held = kf.properties[prop];
|
|
183
|
+
else {
|
|
184
|
+
held ??= backfilled[prop] ?? implicit(prop, "start");
|
|
185
|
+
if (held == null) return false;
|
|
186
|
+
kf.properties[prop] = held;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
167
193
|
/** On a keyframe, change it; between two, add one at the playhead; outside the tween, add one there
|
|
168
194
|
* and keep the authored ends. Values come from the file's tween or GSAP's parse, never the DOM. */
|
|
169
195
|
// fallow-ignore-next-line complexity
|
|
@@ -176,6 +202,7 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
|
|
|
176
202
|
if ("reason" in norm) return refuse(norm.reason);
|
|
177
203
|
const keyframes = norm.keyframes;
|
|
178
204
|
const authored = keyframes.length;
|
|
205
|
+
const steps = anim.keyframes?.format === "object-array" ? [...keyframes] : [];
|
|
179
206
|
// GSAP runs `scale` beside scaleX/scaleY and the longhands win, so a per-axis edit splits it.
|
|
180
207
|
if ("scaleX" in values || "scaleY" in values) {
|
|
181
208
|
for (const kf of keyframes) {
|
|
@@ -184,11 +211,14 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
|
|
|
184
211
|
}
|
|
185
212
|
}
|
|
186
213
|
const backfilled: Record<string, number> = {};
|
|
214
|
+
const pauseStepsHoldThePrevious = new Set(
|
|
215
|
+
steps.filter((kf) => Object.keys(kf.properties).length === 0),
|
|
216
|
+
);
|
|
187
217
|
for (const [prop, value] of Object.entries(edit.backfill ?? {})) {
|
|
188
218
|
if (!(prop in values) || keyframes.some((kf) => kf.properties[prop] != null)) continue;
|
|
189
219
|
if (edit.holdFromStart && !keyframes.some((kf) => kf.percentage <= 0))
|
|
190
220
|
keyframes.unshift({ percentage: 0, properties: {} });
|
|
191
|
-
for (const kf of keyframes) kf.properties[prop] = value;
|
|
221
|
+
for (const kf of keyframes) if (!pauseStepsHoldThePrevious.has(kf)) kf.properties[prop] = value;
|
|
192
222
|
backfilled[prop] = value;
|
|
193
223
|
}
|
|
194
224
|
if (Object.keys(values).some((prop) => !keyframes.some((kf) => kf.properties[prop] != null)))
|
|
@@ -204,7 +234,9 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
|
|
|
204
234
|
if (pct >= -KEYFRAME_PCT_MATCH && pct <= 100 + KEYFRAME_PCT_MATCH) {
|
|
205
235
|
const at = Math.min(100, Math.max(0, pct));
|
|
206
236
|
const hit = keyframeAt(keyframes, at);
|
|
207
|
-
|
|
237
|
+
// The ease moves where a time lands, not a keyframe's own percentage or the tween's ends.
|
|
238
|
+
const onKeyframe = "percentage" in edit.at || hit?.percentage === 0 || hit?.percentage === 100;
|
|
239
|
+
if (eased && !(hit && onKeyframe)) return refuse("eased-keyframes");
|
|
208
240
|
const next = [...keyframes]
|
|
209
241
|
.sort((a, b) => a.percentage - b.percentage)
|
|
210
242
|
.find((kf) => kf.percentage > at + KEYFRAME_PCT_MATCH);
|
|
@@ -231,6 +263,8 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
|
|
|
231
263
|
upsert(keyframes, before ? 0 : 100, { ...held, ...values }, before ? undefined : linear);
|
|
232
264
|
}
|
|
233
265
|
|
|
266
|
+
if (!holdOmittedChannels(steps, edit.implicitEndValue, backfilled))
|
|
267
|
+
return refuse("implicit-end-unknown");
|
|
234
268
|
keyframes.sort((a, b) => a.percentage - b.percentage);
|
|
235
269
|
return {
|
|
236
270
|
ok: true,
|
|
@@ -263,7 +297,10 @@ export function planValueEdit(
|
|
|
263
297
|
const timed = withExactStepTimes(anim, tween);
|
|
264
298
|
return planValueAtPlayhead({
|
|
265
299
|
anim: withLiveTiming(timed, tween),
|
|
266
|
-
at:
|
|
300
|
+
at:
|
|
301
|
+
activeKeyframePct != null
|
|
302
|
+
? { percentage: exactKeyframePct(anim, tween, activeKeyframePct) }
|
|
303
|
+
: { time: currentTime },
|
|
267
304
|
values,
|
|
268
305
|
backfill,
|
|
269
306
|
holdFromStart,
|
|
@@ -286,6 +323,9 @@ export async function commitValueAtPlayhead(
|
|
|
286
323
|
): Promise<GsapEditOutcome> {
|
|
287
324
|
await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
288
325
|
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
326
|
+
const tween = findParsedTween(iframe, selection.element, anim);
|
|
327
|
+
const parkAt =
|
|
328
|
+
activeKeyframePct == null ? null : exactKeyframePct(anim, tween, activeKeyframePct);
|
|
289
329
|
const plan = planValueEdit(selection, anim, values, iframe, options);
|
|
290
330
|
if (!plan.ok) return { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason };
|
|
291
331
|
await callbacks.commitMutation(selection, plan.mutation, {
|
|
@@ -294,9 +334,9 @@ export async function commitValueAtPlayhead(
|
|
|
294
334
|
beforeReload: options.beforeReload,
|
|
295
335
|
...(plan.added && { keyframeAction: "add" as const }),
|
|
296
336
|
});
|
|
297
|
-
if (
|
|
337
|
+
if (parkAt != null) {
|
|
298
338
|
setActiveKeyframePct(null);
|
|
299
|
-
parkPlayheadOnKeyframe(anim,
|
|
339
|
+
parkPlayheadOnKeyframe(anim, parkAt);
|
|
300
340
|
}
|
|
301
341
|
return { status: "persisted" };
|
|
302
342
|
}
|