@hyperframes/studio 0.8.134 → 0.8.136
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-Bro1gj0Z.js → hyperframes-player-ukvUFqdg.js} +1 -1
- package/dist/assets/{index-DnVGIbEC.js → index-BY_myArg.js} +1 -1
- package/dist/assets/{index-wnXyQjW-.js → index-C-pD9_xY.js} +185 -184
- package/dist/assets/{index-BwNDTmAD.js → index-CRRgIxsS.js} +1 -1
- package/dist/assets/{index-D11atkBt.css → index-syMK0_9C.css} +1 -1
- package/dist/{chunk-5VSXRGIX.js → chunk-NM7BXNZ7.js} +1 -1
- package/dist/chunk-NM7BXNZ7.js.map +1 -0
- package/dist/{chunk-FOEID72M.js → chunk-QRLKWENQ.js} +2 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.html +2 -2
- package/dist/index.js +227 -88
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +3 -2
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/editor/AnimationCard.test.tsx +60 -0
- package/src/components/editor/AnimationCard.tsx +16 -6
- package/src/components/editor/KeyframeEaseList.tsx +4 -1
- package/src/components/editor/LayersPanel.tsx +39 -47
- package/src/components/editor/domEditInlineText.ts +1 -1
- package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
- package/src/components/editor/gsapAnimationHelpers.ts +34 -0
- package/src/components/editor/layerKind.test.ts +38 -0
- package/src/components/editor/layerKind.ts +19 -0
- package/src/components/editor/useDomEditNudge.test.tsx +48 -27
- package/src/components/ui/cn.ts +2 -1
- package/src/contexts/DomEditContext.tsx +3 -1
- package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
- package/src/hooks/gsapRuntimePatch.ts +4 -0
- package/src/hooks/gsapShared.test.ts +11 -0
- package/src/hooks/gsapShared.ts +1 -1
- package/src/hooks/keyframeEase.realGsap.test.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +33 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +3 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +21 -0
- package/src/hooks/useDomGeometryCommits.ts +6 -1
- package/src/hooks/useGestureCommit.test.tsx +89 -0
- package/src/hooks/useGestureCommit.ts +26 -9
- package/src/icons/SystemIcons.tsx +6 -0
- package/src/layersPanelExport.test.tsx +45 -4
- package/src/utils/gsapKeyframeEases.ts +2 -0
- package/src/utils/gsapLiveRetime.cache.test.ts +39 -0
- package/src/utils/gsapLiveRetime.realGsap.test.ts +39 -15
- package/src/utils/gsapLiveRetime.ts +39 -31
- package/src/utils/gsapSoftReload.test.ts +98 -14
- package/src/utils/gsapSoftReload.ts +14 -2
- package/src/utils/gsapUndoRestore.test.ts +21 -2
- package/src/utils/softReloadTargets.ts +56 -0
- package/src/utils/studioPendingEdits.test.ts +123 -0
- package/src/utils/studioPendingEdits.ts +27 -1
- package/dist/chunk-5VSXRGIX.js.map +0 -1
- /package/dist/{chunk-FOEID72M.js.map → chunk-QRLKWENQ.js.map} +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { gsap } from "gsap";
|
|
2
|
+
|
|
3
|
+
type KeyframeMutation = {
|
|
4
|
+
duration: number;
|
|
5
|
+
ease?: string;
|
|
6
|
+
easeEach?: string;
|
|
7
|
+
keyframes: Array<{
|
|
8
|
+
percentage: number;
|
|
9
|
+
properties: Record<string, number | string>;
|
|
10
|
+
ease?: string;
|
|
11
|
+
}>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Plays a replace-with-keyframes mutation in real GSAP and reads `x` at `time` seconds. */
|
|
15
|
+
export function xAtTime(mutation: KeyframeMutation, time: number): number {
|
|
16
|
+
const target = { x: 0, y: 0, opacity: 1 };
|
|
17
|
+
const frames: Record<string, unknown> = mutation.easeEach ? { easeEach: mutation.easeEach } : {};
|
|
18
|
+
for (const kf of mutation.keyframes) {
|
|
19
|
+
frames[`${kf.percentage}%`] = { ...kf.properties, ...(kf.ease ? { ease: kf.ease } : {}) };
|
|
20
|
+
}
|
|
21
|
+
const vars = {
|
|
22
|
+
duration: mutation.duration,
|
|
23
|
+
keyframes: frames,
|
|
24
|
+
...(mutation.ease ? { ease: mutation.ease } : {}),
|
|
25
|
+
};
|
|
26
|
+
gsap.timeline({ paused: true }).to(target, vars, 0).seek(time);
|
|
27
|
+
return target.x;
|
|
28
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type RuntimeTween,
|
|
20
20
|
type RuntimeTimeline,
|
|
21
21
|
} from "./gsapRuntimeKeyframes";
|
|
22
|
+
import { recordLiveSet } from "../utils/softReloadTargets";
|
|
22
23
|
|
|
23
24
|
/** Value-only channels a `tl.set(...)` patch may touch. */
|
|
24
25
|
export interface SetPatchProps {
|
|
@@ -34,6 +35,8 @@ export interface SetPatchProps {
|
|
|
34
35
|
scaleY?: number;
|
|
35
36
|
scale?: number;
|
|
36
37
|
opacity?: number;
|
|
38
|
+
width?: number;
|
|
39
|
+
height?: number;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/** A single keyframe step's numeric channels (the GSAP array-keyframe form). */
|
|
@@ -101,6 +104,7 @@ function applyGlobalSet(
|
|
|
101
104
|
const numeric = finiteNumericProps(props);
|
|
102
105
|
if (!gsapLib?.set || !el || !numeric) return false;
|
|
103
106
|
gsapLib.set(el, numeric);
|
|
107
|
+
recordLiveSet(el, numeric);
|
|
104
108
|
return true;
|
|
105
109
|
} catch {
|
|
106
110
|
return false;
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
idFromSelector,
|
|
5
5
|
idSelector,
|
|
6
6
|
isInstantHold,
|
|
7
|
+
keyframeEases,
|
|
7
8
|
parsePercentageKeyframes,
|
|
8
9
|
resolveClipTimingBasis,
|
|
9
10
|
playsNear,
|
|
@@ -11,6 +12,16 @@ import {
|
|
|
11
12
|
toClipPercentage,
|
|
12
13
|
} from "./gsapShared";
|
|
13
14
|
|
|
15
|
+
describe("keyframeEases", () => {
|
|
16
|
+
it("carries the keyframes' own ease into the tween ease, which GSAP plays the same", () => {
|
|
17
|
+
const anim = {
|
|
18
|
+
ease: "back.out",
|
|
19
|
+
keyframes: { keyframes: [], ease: "sine.inOut", easeEach: "power2.out" },
|
|
20
|
+
} as unknown as GsapAnimation;
|
|
21
|
+
expect(keyframeEases(anim)).toEqual({ ease: "sine.inOut", easeEach: "power2.out" });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
14
25
|
describe("isInstantHold", () => {
|
|
15
26
|
const animation = (method: GsapAnimation["method"], duration?: number) =>
|
|
16
27
|
({ method, duration }) as unknown as GsapAnimation;
|
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -41,7 +41,7 @@ export const PROPERTY_DEFAULTS: Record<string, number> = {
|
|
|
41
41
|
* since GSAP eases each percentage segment power1.inOut whatever the tween-level ease says. */
|
|
42
42
|
export function keyframeEases(anim: GsapAnimation): { ease?: string; easeEach?: string } {
|
|
43
43
|
if (!anim.keyframes) return { easeEach: anim.ease };
|
|
44
|
-
return { ease: anim
|
|
44
|
+
return { ease: runEaseOf(anim), easeEach: anim.keyframes.easeEach };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Real gsap: the ease the parser reports for keyframes must be the one GSAP plays.
|
|
2
|
+
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
|
3
|
+
import { gsap } from "gsap";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
const STEPS = { "0%": { x: 0 }, "100%": { x: 10 } };
|
|
7
|
+
|
|
8
|
+
function xAt(vars: gsap.TweenVars, time = 0.3): number {
|
|
9
|
+
const target = { x: 0 };
|
|
10
|
+
gsap
|
|
11
|
+
.timeline({ paused: true })
|
|
12
|
+
.to(target, { duration: 1, ...vars }, 0)
|
|
13
|
+
.seek(time);
|
|
14
|
+
return target.x;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const parsedEaseEach = (vars: string) =>
|
|
18
|
+
parseGsapScriptAcorn(`const tl = gsap.timeline({ paused: true });
|
|
19
|
+
tl.to("#a", { duration: 1, ${vars} }, 0);`).animations[0]!.keyframes?.easeEach;
|
|
20
|
+
|
|
21
|
+
describe("the keyframes ease the parser reports", () => {
|
|
22
|
+
it("is not a tween-level easeEach, which GSAP ignores", () => {
|
|
23
|
+
expect(xAt({ keyframes: STEPS, easeEach: "power4.in" })).toBe(xAt({ keyframes: STEPS }));
|
|
24
|
+
expect(
|
|
25
|
+
parsedEaseEach('keyframes: { "0%": { x: 0 }, "100%": { x: 10 } }, easeEach: "power4.in"'),
|
|
26
|
+
).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("is an easeEach inside the keyframes, which GSAP plays", () => {
|
|
30
|
+
expect(xAt({ keyframes: { ...STEPS, easeEach: "power4.in" } })).not.toBe(
|
|
31
|
+
xAt({ keyframes: STEPS }),
|
|
32
|
+
);
|
|
33
|
+
expect(
|
|
34
|
+
parsedEaseEach('keyframes: { "0%": { x: 0 }, "100%": { x: 10 }, easeEach: "power4.in" }'),
|
|
35
|
+
).toBe("power4.in");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -10,6 +10,7 @@ import { mountReactHarness, withInlineLayoutBox } from "./domSelectionTestHarnes
|
|
|
10
10
|
import { writeSizeWithCrop } from "../components/editor/cropResize";
|
|
11
11
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
12
12
|
import { trackKeyframeCommit } from "../utils/keyframeUsage";
|
|
13
|
+
import { xAtTime } from "./gsapPlaybackTestHarness";
|
|
13
14
|
import type { CommitMutationOptions, CommitMutation } from "./gsapScriptCommitTypes";
|
|
14
15
|
|
|
15
16
|
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
@@ -205,6 +206,38 @@ function renderCommitHook(
|
|
|
205
206
|
// canvas nudge exercises (not the raw drag intercept) — with auto-keyframe
|
|
206
207
|
// off, it must shift the whole tween instead of adding/updating a keyframe
|
|
207
208
|
// at the playhead.
|
|
209
|
+
describe("useAnimatedPropertyCommit — extending a keyframed tween to the playhead", () => {
|
|
210
|
+
it("keeps the tween's eases and each keyframe's own ease", async () => {
|
|
211
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 3, activeKeyframePct: null });
|
|
212
|
+
const eased = {
|
|
213
|
+
...keyframedAnim,
|
|
214
|
+
ease: "power2.inOut",
|
|
215
|
+
keyframes: {
|
|
216
|
+
easeEach: "power2.out",
|
|
217
|
+
keyframes: [
|
|
218
|
+
{ percentage: 0, properties: { x: 0, y: 0 }, ease: "expo.in" },
|
|
219
|
+
{ percentage: 100, properties: { x: 100, y: 0 } },
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
} as unknown as GsapAnimation;
|
|
223
|
+
const mutations: Array<Record<string, unknown>> = [];
|
|
224
|
+
let commit!: Commit;
|
|
225
|
+
const root = renderHookWith(
|
|
226
|
+
[eased],
|
|
227
|
+
(mutation) => mutations.push(mutation),
|
|
228
|
+
(ready) => (commit = ready),
|
|
229
|
+
);
|
|
230
|
+
await act(async () => commit(selection, { x: 50 }));
|
|
231
|
+
act(() => root.unmount());
|
|
232
|
+
|
|
233
|
+
const replace = mutations.find((m) => m.type === "replace-with-keyframes")!;
|
|
234
|
+
expect(replace).toMatchObject({ ease: "power2.inOut", easeEach: "power2.out" });
|
|
235
|
+
expect((replace.keyframes as Array<{ ease?: string }>)[0]!.ease).toBe("expo.in");
|
|
236
|
+
// The old end keyframe still plays at 2 s once the tween runs to the playhead at 3 s.
|
|
237
|
+
expect(xAtTime(replace as unknown as Parameters<typeof xAtTime>[0], 2)).toBeCloseTo(100, 0);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
208
241
|
describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", () => {
|
|
209
242
|
async function runCommitWithAutoKeyframe(enabled: boolean) {
|
|
210
243
|
usePlayerStore.setState({ autoKeyframeEnabled: enabled, currentTime: 0 });
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
selectorFromSelection,
|
|
19
19
|
computeElementPercentage,
|
|
20
20
|
isInstantHold,
|
|
21
|
+
keyframeEases,
|
|
21
22
|
writeTargetSelector,
|
|
22
23
|
tweenTargetsElement,
|
|
23
24
|
} from "./gsapShared";
|
|
@@ -375,7 +376,7 @@ async function commitKeyframeProps(
|
|
|
375
376
|
for (const k of Object.keys(properties)) {
|
|
376
377
|
if (!(k in p) && backfillDefaults[k] != null) p[k] = backfillDefaults[k];
|
|
377
378
|
}
|
|
378
|
-
return { percentage: newPct, properties: p };
|
|
379
|
+
return { percentage: newPct, properties: p, ...(kf.ease ? { ease: kf.ease } : {}) };
|
|
379
380
|
});
|
|
380
381
|
remapped.push({ percentage: toNewPct(ct), properties });
|
|
381
382
|
remapped.sort((a, b) => a.percentage - b.percentage);
|
|
@@ -388,6 +389,7 @@ async function commitKeyframeProps(
|
|
|
388
389
|
position: roundTo3(newStart),
|
|
389
390
|
duration: roundTo3(newDuration),
|
|
390
391
|
keyframes: remapped,
|
|
392
|
+
...keyframeEases(anim),
|
|
391
393
|
},
|
|
392
394
|
{ label: `Edit ${primaryProp} (extended keyframe)`, keyframeAction: "add", softReload: true },
|
|
393
395
|
);
|
|
@@ -376,6 +376,27 @@ describe("Reset after a plain move puts the author's translate back", () => {
|
|
|
376
376
|
});
|
|
377
377
|
});
|
|
378
378
|
|
|
379
|
+
describe("useDomGeometryCommits undo steps", () => {
|
|
380
|
+
it("never merges a resize with the next size edit of the same element", async () => {
|
|
381
|
+
const element = document.createElement("div");
|
|
382
|
+
element.id = "box";
|
|
383
|
+
document.body.append(element);
|
|
384
|
+
const selection = { id: "box", selector: "#box", element } as unknown as DomEditSelection;
|
|
385
|
+
const commitPositionPatchToHtml = vi
|
|
386
|
+
.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
|
|
387
|
+
.mockResolvedValue(undefined);
|
|
388
|
+
const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
|
|
389
|
+
|
|
390
|
+
await commits().handleDomBoxSizeCommit(selection, { width: 418, height: 200 });
|
|
391
|
+
await commits().handleDomBoxSizeCommit(selection, { width: 280, height: 200 });
|
|
392
|
+
|
|
393
|
+
const [resize, field] = commitPositionPatchToHtml.mock.calls.map(([, , options]) => options);
|
|
394
|
+
expect(resize!.coalesceKey).not.toBe(field!.coalesceKey);
|
|
395
|
+
unmount();
|
|
396
|
+
element.remove();
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
379
400
|
describe("useDomGeometryCommits resize of a cropped element", () => {
|
|
380
401
|
it("saves the crop scaled per axis with the box in the resize's own commit", async () => {
|
|
381
402
|
const element = withInlineLayoutBox(document.createElement("div"));
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
} from "../components/editor/manualEditsDomPatches";
|
|
27
27
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
28
28
|
|
|
29
|
+
let boxSizeCommitCounter = 0;
|
|
30
|
+
|
|
29
31
|
// ── Hook ──
|
|
30
32
|
|
|
31
33
|
export interface UseDomGeometryCommitsParams {
|
|
@@ -90,7 +92,10 @@ export function useDomGeometryCommits({
|
|
|
90
92
|
label: "Resize layer box",
|
|
91
93
|
...(undoKey
|
|
92
94
|
? { coalesceKey: undoKey, coalesceMs: Number.POSITIVE_INFINITY, deferRender: true }
|
|
93
|
-
: {
|
|
95
|
+
: {
|
|
96
|
+
coalesceKey: `box-size:${++boxSizeCommitCounter}`,
|
|
97
|
+
coalesceMs: Number.POSITIVE_INFINITY,
|
|
98
|
+
}),
|
|
94
99
|
}).catch((error) => {
|
|
95
100
|
restoreStudioBoxSize(element, beforeSize);
|
|
96
101
|
if (offset) restoreStudioPathOffset(element, beforeOffset);
|
|
@@ -10,6 +10,7 @@ import { mountReactHarness } from "./domSelectionTestHarness";
|
|
|
10
10
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
11
11
|
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
12
12
|
import { useGestureCommit } from "./useGestureCommit";
|
|
13
|
+
import { xAtTime } from "./gsapPlaybackTestHarness";
|
|
13
14
|
|
|
14
15
|
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
15
16
|
|
|
@@ -243,6 +244,94 @@ it.each([
|
|
|
243
244
|
},
|
|
244
245
|
);
|
|
245
246
|
|
|
247
|
+
const FROM_0_TO_100 = [
|
|
248
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
249
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
function positionTween(
|
|
253
|
+
duration: number,
|
|
254
|
+
eases: { ease?: string; easeEach?: string } = {},
|
|
255
|
+
keyframes = FROM_0_TO_100,
|
|
256
|
+
) {
|
|
257
|
+
return {
|
|
258
|
+
id: "card-position",
|
|
259
|
+
targetSelector: "#card",
|
|
260
|
+
propertyGroup: "position",
|
|
261
|
+
method: "to",
|
|
262
|
+
properties: { x: 0, y: 0 },
|
|
263
|
+
resolvedStart: 0,
|
|
264
|
+
position: 0,
|
|
265
|
+
duration,
|
|
266
|
+
...(eases.ease ? { ease: eases.ease } : {}),
|
|
267
|
+
keyframes: {
|
|
268
|
+
...(eases.easeEach ? { easeEach: eases.easeEach } : {}),
|
|
269
|
+
keyframes,
|
|
270
|
+
},
|
|
271
|
+
} as unknown as GsapAnimation;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Records the mocked 1 s gesture (x 0, 50, 100) from 0 s over `tween`; resolves to the merge mutation. */
|
|
275
|
+
async function recordMergeInto(tween: GsapAnimation) {
|
|
276
|
+
const writer = vi.fn(
|
|
277
|
+
async (_mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
278
|
+
options.onResult?.({ ok: true, changed: true });
|
|
279
|
+
},
|
|
280
|
+
);
|
|
281
|
+
const hook = mountRecording(writer, [tween]);
|
|
282
|
+
act(() => hook().handleToggleRecording("button"));
|
|
283
|
+
await act(async () => {
|
|
284
|
+
hook().handleToggleRecording();
|
|
285
|
+
await vi.waitFor(() => expect(writer).toHaveBeenCalled());
|
|
286
|
+
});
|
|
287
|
+
expect(writer.mock.calls[0]![1]).toMatchObject({ label: "Gesture recording (merge)" });
|
|
288
|
+
return writer.mock.calls[0]![0] as unknown as Parameters<typeof xAtTime>[0] & {
|
|
289
|
+
type: string;
|
|
290
|
+
ease?: string;
|
|
291
|
+
easeEach?: string;
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
it("keeps the tween's eases when a recording merges into it", async () => {
|
|
296
|
+
const merge = await recordMergeInto(
|
|
297
|
+
positionTween(2, { ease: "back.out", easeEach: "power2.out" }),
|
|
298
|
+
);
|
|
299
|
+
expect(merge).toMatchObject({
|
|
300
|
+
type: "replace-with-keyframes",
|
|
301
|
+
ease: "back.out",
|
|
302
|
+
easeEach: "power2.out",
|
|
303
|
+
});
|
|
304
|
+
// Recorded segments the fitter left unset stay constant speed instead of taking easeEach.
|
|
305
|
+
expect(merge.keyframes.map((kf) => kf.ease)).toEqual([undefined, "none", "none", undefined]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("plays a recording merged into an eased tween at the times it was recorded", async () => {
|
|
309
|
+
const merge = await recordMergeInto(positionTween(3, { ease: "power2.out" }));
|
|
310
|
+
expect(xAtTime(merge, 0.5)).toBeCloseTo(50, 0);
|
|
311
|
+
expect(xAtTime(merge, 1)).toBeCloseTo(100, 0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("keeps recorded keyframes apart over the tail of a power4.out tween", async () => {
|
|
315
|
+
usePlayerStore.setState({ currentTime: 9 });
|
|
316
|
+
const merge = await recordMergeInto(positionTween(10, { ease: "power4.out" }));
|
|
317
|
+
expect(xAtTime(merge, 9.5)).toBeCloseTo(50, 0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("keeps a keyframe playing at 8.5 s when a recording covers 9-10 s of a power2.out tween", async () => {
|
|
321
|
+
usePlayerStore.setState({ currentTime: 9 });
|
|
322
|
+
const at8_5s = { percentage: 99.6625, properties: { x: 80 } };
|
|
323
|
+
const merge = await recordMergeInto(
|
|
324
|
+
positionTween(10, { ease: "power2.out" }, [FROM_0_TO_100[0]!, at8_5s, FROM_0_TO_100[1]!]),
|
|
325
|
+
);
|
|
326
|
+
expect(merge.keyframes).toContainEqual(at8_5s);
|
|
327
|
+
expect(xAtTime(merge, 8.5)).toBeCloseTo(80, 0);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("writes merged keyframe percentages rounded, not as float noise", async () => {
|
|
331
|
+
const merge = await recordMergeInto(positionTween(3));
|
|
332
|
+
expect(merge.keyframes.map((kf) => kf.percentage)).toEqual([0, 16.667, 33.333, 100]);
|
|
333
|
+
});
|
|
334
|
+
|
|
246
335
|
it("keeps a pending recording isolated until its writer settles", async () => {
|
|
247
336
|
let release!: () => void;
|
|
248
337
|
const pending = new Promise<void>((resolve) => {
|
|
@@ -16,8 +16,15 @@ import { trackPreviewEditResult } from "../utils/previewFeatureUsage";
|
|
|
16
16
|
import { observeGsapGesture } from "./gsapGestureOutcome";
|
|
17
17
|
import { roundTo3 } from "../utils/rounding";
|
|
18
18
|
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
isInstantHold,
|
|
21
|
+
idSelector,
|
|
22
|
+
keyframeEases,
|
|
23
|
+
writeTargetSelector,
|
|
24
|
+
tweenTargetsElement,
|
|
25
|
+
} from "./gsapShared";
|
|
20
26
|
import { useStableHandlers } from "./useStableHandlers";
|
|
27
|
+
import { progressAtTime, runEaseOf, timeAtProgress, warpsTime } from "../utils/gsapKeyframeEases";
|
|
21
28
|
|
|
22
29
|
type RecordedKeyframe = {
|
|
23
30
|
percentage: number;
|
|
@@ -269,25 +276,34 @@ export function useGestureCommit({
|
|
|
269
276
|
|
|
270
277
|
if (overlaps) {
|
|
271
278
|
const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
|
|
272
|
-
const
|
|
279
|
+
const startTimePct =
|
|
273
280
|
tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0;
|
|
274
|
-
const
|
|
281
|
+
const endTimePct =
|
|
275
282
|
tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100;
|
|
283
|
+
const runEase = runEaseOf(existingPositionTween);
|
|
284
|
+
const roundPct = warpsTime(runEase) ? (pct: number) => pct : roundTo3;
|
|
276
285
|
|
|
277
286
|
const preserved = existingKfs
|
|
278
|
-
.filter(
|
|
279
|
-
|
|
280
|
-
|
|
287
|
+
.filter((kf) => {
|
|
288
|
+
const playsAt = timeAtProgress(runEase, kf.percentage);
|
|
289
|
+
return playsAt < startTimePct - 0.5 || playsAt > endTimePct + 0.5;
|
|
290
|
+
})
|
|
281
291
|
.map((kf) => ({
|
|
282
292
|
percentage: kf.percentage,
|
|
283
293
|
properties: kf.properties,
|
|
284
294
|
...(kf.ease ? { ease: kf.ease } : {}),
|
|
285
295
|
}));
|
|
286
296
|
|
|
287
|
-
const
|
|
288
|
-
|
|
297
|
+
const constantSpeed = { ease: "none" };
|
|
298
|
+
const mapped = keyframes.map((kf, index) => ({
|
|
299
|
+
percentage: roundPct(
|
|
300
|
+
progressAtTime(
|
|
301
|
+
runEase,
|
|
302
|
+
startTimePct + (kf.percentage / 100) * (endTimePct - startTimePct),
|
|
303
|
+
),
|
|
304
|
+
),
|
|
289
305
|
properties: kf.properties,
|
|
290
|
-
...(kf.ease ? { ease: kf.ease } : {}),
|
|
306
|
+
...(kf.ease ? { ease: kf.ease } : index > 0 ? constantSpeed : {}),
|
|
291
307
|
}));
|
|
292
308
|
|
|
293
309
|
const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
|
|
@@ -303,6 +319,7 @@ export function useGestureCommit({
|
|
|
303
319
|
: tweenStart,
|
|
304
320
|
duration: tweenDur,
|
|
305
321
|
keyframes: merged,
|
|
322
|
+
...keyframeEases(existingPositionTween),
|
|
306
323
|
},
|
|
307
324
|
{ label: "Gesture recording (merge)", softReload: true, keyframeAction: "add" },
|
|
308
325
|
);
|
|
@@ -24,6 +24,9 @@ import {
|
|
|
24
24
|
Link as PhLink,
|
|
25
25
|
Eyedropper as PhEyedropper,
|
|
26
26
|
Trash as PhTrash,
|
|
27
|
+
Image as PhImage,
|
|
28
|
+
BezierCurve,
|
|
29
|
+
FolderSimple,
|
|
27
30
|
} from "@phosphor-icons/react";
|
|
28
31
|
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
|
29
32
|
|
|
@@ -75,3 +78,6 @@ export const Scissors = makeIcon(PhScissors);
|
|
|
75
78
|
export const Link = makeIcon(PhLink);
|
|
76
79
|
export const Eyedropper = makeIcon(PhEyedropper);
|
|
77
80
|
export const Trash = makeIcon(PhTrash);
|
|
81
|
+
export const ImageIcon = makeIcon(PhImage);
|
|
82
|
+
export const Vector = makeIcon(BezierCurve);
|
|
83
|
+
export const Folder = makeIcon(FolderSimple);
|
|
@@ -29,12 +29,17 @@ afterEach(() => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
/** The host's preview (title in front of bg, rows to match) and its edit session, mounted around the panel. */
|
|
32
|
-
|
|
32
|
+
const TWO_BOXES =
|
|
33
|
+
'<div data-composition-id="main"><div id="bg" style="z-index:1"></div><div id="title" style="z-index:2"></div></div>';
|
|
34
|
+
|
|
35
|
+
async function mountPanel(
|
|
36
|
+
wrap: (panel: ReactNode) => ReactNode = (panel) => panel,
|
|
37
|
+
picture = TWO_BOXES,
|
|
38
|
+
) {
|
|
33
39
|
const iframe = document.createElement("iframe");
|
|
34
40
|
document.body.appendChild(iframe);
|
|
35
41
|
const doc = iframe.contentDocument!;
|
|
36
|
-
doc.body.innerHTML =
|
|
37
|
-
'<div data-composition-id="main"><div id="bg" style="z-index:1"></div><div id="title" style="z-index:2"></div></div>';
|
|
42
|
+
doc.body.innerHTML = picture;
|
|
38
43
|
usePlayerStore.setState({ elements: [clip("title", 0), clip("bg", 1)] });
|
|
39
44
|
const session = {
|
|
40
45
|
domEditSelection: null,
|
|
@@ -84,7 +89,7 @@ async function dragBackRowToFront() {
|
|
|
84
89
|
it("lists, selects and reorders the host's preview layers through the host's session", async () => {
|
|
85
90
|
const { doc, session, root } = await mountPanel();
|
|
86
91
|
|
|
87
|
-
expect(rowLabels()).toEqual(["
|
|
92
|
+
expect(rowLabels()).toEqual(["Title", "Bg"]);
|
|
88
93
|
|
|
89
94
|
const rows = document.querySelectorAll<HTMLElement>("[data-layer-index]");
|
|
90
95
|
await act(async () => rows[0]!.click());
|
|
@@ -113,3 +118,39 @@ it("mirrors the reorder into the timeline rows through the host's move handler",
|
|
|
113
118
|
expect(bgTrack()).not.toBe(1);
|
|
114
119
|
await act(async () => root.unmount());
|
|
115
120
|
});
|
|
121
|
+
|
|
122
|
+
it("puts a caret only on a group's row and starts each child's icon where its parent's label starts", async () => {
|
|
123
|
+
const { root } = await mountPanel(
|
|
124
|
+
undefined,
|
|
125
|
+
'<div data-composition-id="main"><h1 id="title" style="z-index:2">Hi</h1>' +
|
|
126
|
+
'<div id="intro" data-hf-group="Intro" style="z-index:1"><img id="logo">' +
|
|
127
|
+
'<div id="inner" data-hf-group="Inner"><img id="mark"></div></div>' +
|
|
128
|
+
'<div id="wrap" style="z-index:0"><div id="box"></div></div></div>',
|
|
129
|
+
);
|
|
130
|
+
const rows = [...document.querySelectorAll<HTMLElement>("[data-layer-index]")];
|
|
131
|
+
const byLabel = (label: string) => rows.find((row) => row.textContent?.startsWith(label))!;
|
|
132
|
+
const kind = (label: string) =>
|
|
133
|
+
byLabel(label).querySelector("[data-layer-kind]")?.getAttribute("data-layer-kind");
|
|
134
|
+
expect(["Title", "Intro", "Logo", "Inner", "Mark"].map(kind)).toEqual([
|
|
135
|
+
"text",
|
|
136
|
+
"group",
|
|
137
|
+
"image",
|
|
138
|
+
"group",
|
|
139
|
+
"image",
|
|
140
|
+
]);
|
|
141
|
+
expect([kind("Wrap"), kind("Box")]).toEqual(["group", "shape"]);
|
|
142
|
+
expect(byLabel("Wrap").firstElementChild?.getAttribute("aria-label")).toBe("Collapse children");
|
|
143
|
+
const icons = ["Title", "Intro", "Logo", "Box"].map(
|
|
144
|
+
(l) => byLabel(l).querySelector("[data-layer-kind]")!.innerHTML,
|
|
145
|
+
);
|
|
146
|
+
expect(new Set(icons).size).toBe(4);
|
|
147
|
+
for (const row of rows.filter((r) => r.title)) {
|
|
148
|
+
expect(row.querySelector<HTMLElement>("[data-layer-kind]")!.title).toContain(row.title);
|
|
149
|
+
}
|
|
150
|
+
expect(byLabel("Title").firstElementChild?.hasAttribute("data-layer-kind")).toBe(true);
|
|
151
|
+
expect(byLabel("Intro").firstElementChild?.getAttribute("aria-label")).toBe("Collapse children");
|
|
152
|
+
expect(
|
|
153
|
+
["Title", "Intro", "Logo", "Inner", "Mark"].map((l) => byLabel(l).style.paddingLeft),
|
|
154
|
+
).toEqual(["8px", "8px", "56px", "56px", "104px"]);
|
|
155
|
+
await act(async () => root.unmount());
|
|
156
|
+
});
|
|
@@ -50,6 +50,8 @@ function timingEase(runEase: string | undefined): ((progress: number) => number)
|
|
|
50
50
|
return timingEases.get(runEase) ?? null;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export const warpsTime = (runEase: string | undefined) => timingEase(runEase) !== null;
|
|
54
|
+
|
|
53
55
|
/** The keyframe progress (0-100) GSAP shows at `timePercentage` of a tween with this run ease. */
|
|
54
56
|
export function progressAtTime(runEase: string | undefined, timePercentage: number): number {
|
|
55
57
|
const run = timingEase(runEase);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const reads = vi.hoisted(() => ({ shape: [] as string[] }));
|
|
4
|
+
|
|
5
|
+
vi.mock("@hyperframes/parsers/gsap-parser-acorn", async (importOriginal) => {
|
|
6
|
+
const real = await importOriginal<typeof import("@hyperframes/parsers/gsap-parser-acorn")>();
|
|
7
|
+
return {
|
|
8
|
+
...real,
|
|
9
|
+
scriptShape: (code: string, mask?: boolean) => (
|
|
10
|
+
reads.shape.push(code), real.scriptShape(code, mask)
|
|
11
|
+
),
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const { planLiveRetime } = await import("./gsapLiveRetime");
|
|
16
|
+
|
|
17
|
+
const at = (start: number) =>
|
|
18
|
+
`var tl = gsap.timeline({ paused: true });\ntl.to("#a", { x: 1, duration: 1 }, ${start});\nwindow.__timelines["t"] = tl;`;
|
|
19
|
+
|
|
20
|
+
describe("planLiveRetime", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
reads.shape.length = 0;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("reads each script's shape once across two drags in a row", () => {
|
|
26
|
+
expect(planLiveRetime(at(0), at(1)).kind).toBe("retime");
|
|
27
|
+
expect(planLiveRetime(at(1), at(2)).kind).toBe("retime");
|
|
28
|
+
|
|
29
|
+
expect(reads.shape).toEqual([at(0), at(1), at(2)]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps the live script's shape while saves land before the preview catches up", () => {
|
|
33
|
+
planLiveRetime(at(10), at(11));
|
|
34
|
+
planLiveRetime(at(10), at(12));
|
|
35
|
+
planLiveRetime(at(10), at(13));
|
|
36
|
+
|
|
37
|
+
expect(reads.shape).toEqual([at(10), at(11), at(12), at(13)]);
|
|
38
|
+
});
|
|
39
|
+
});
|