@hyperframes/studio 0.8.133 → 0.8.135
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-Cjcwveg0.js → hyperframes-player-DFv9heE6.js} +1 -1
- package/dist/assets/{index-CwgWmfgx.js → index-Buy06mZy.js} +1 -1
- package/dist/assets/{index-ME-Z5jh6.js → index-CTp4loy9.js} +1 -1
- package/dist/assets/{index-Dj21zXWA.js → index-N_4Ij4X6.js} +184 -183
- 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 +911 -691
- 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/manualEditsDom.ts +5 -1
- package/src/components/editor/plainTranslate.test.ts +21 -2
- package/src/components/editor/plainTranslate.ts +7 -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/draggedGsapPosition.test.ts +34 -0
- package/src/hooks/draggedGsapPosition.ts +40 -15
- package/src/hooks/gsapDragCommit.ts +17 -10
- package/src/hooks/gsapDragPositionCommit.ts +8 -9
- package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
- package/src/hooks/gsapResizeIntercept.test.ts +72 -1
- package/src/hooks/gsapResizeIntercept.ts +84 -17
- package/src/hooks/gsapRuntimeBridge.ts +4 -1
- 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/gsapTrailingKeyframes.realGsap.test.ts +44 -0
- 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/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +3 -0
- package/src/hooks/useEnableKeyframes.ts +32 -37
- package/src/hooks/useGestureCommit.test.tsx +89 -0
- package/src/hooks/useGestureCommit.ts +26 -9
- package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +10 -2
- package/src/icons/SystemIcons.tsx +6 -0
- package/src/layersPanelExport.test.tsx +45 -4
- package/src/utils/elementGsap.ts +6 -5
- 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 +43 -2
- package/src/utils/gsapUndoRestore.ts +4 -2
- package/src/utils/softReloadTargets.ts +56 -0
- package/src/utils/studioPendingEdits.test.ts +123 -0
- package/src/utils/studioPendingEdits.ts +39 -3
- package/dist/chunk-5VSXRGIX.js.map +0 -1
- /package/dist/{chunk-FOEID72M.js.map → chunk-QRLKWENQ.js.map} +0 -0
|
@@ -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
|
);
|
|
@@ -16,7 +16,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
16
16
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
17
17
|
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
|
|
18
18
|
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
19
|
-
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
19
|
+
import { computeDraggedGsapPosition, freezeDragStamp } from "./draggedGsapPosition";
|
|
20
20
|
import { readGsapPositionFromIframe } from "./gsapPositionDetection";
|
|
21
21
|
import { selectorFromSelection } from "./gsapShared";
|
|
22
22
|
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
|
|
@@ -209,6 +209,7 @@ export function useGsapAwareEditing({
|
|
|
209
209
|
restore: () => void = () => undefined,
|
|
210
210
|
route?: { plainTranslate: boolean },
|
|
211
211
|
) => {
|
|
212
|
+
const stamp = freezeDragStamp(selection.element);
|
|
212
213
|
const writes = observeGsapGesture(gsapCommitMutation);
|
|
213
214
|
if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) {
|
|
214
215
|
const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
|
|
@@ -256,6 +257,7 @@ export function useGsapAwareEditing({
|
|
|
256
257
|
previewIframeRef.current,
|
|
257
258
|
commitMutation,
|
|
258
259
|
makeFetchFallback(selection),
|
|
260
|
+
{ stamp },
|
|
259
261
|
);
|
|
260
262
|
// Saved after the size, under its undo key, so the two are one step.
|
|
261
263
|
await saveMove(dragOutcome, async () => {
|
|
@@ -279,7 +281,12 @@ export function useGsapAwareEditing({
|
|
|
279
281
|
x: 0,
|
|
280
282
|
y: 0,
|
|
281
283
|
};
|
|
282
|
-
const { newX, newY } = computeDraggedGsapPosition(
|
|
284
|
+
const { newX, newY } = computeDraggedGsapPosition(
|
|
285
|
+
selection.element,
|
|
286
|
+
offset,
|
|
287
|
+
gsapPos,
|
|
288
|
+
stamp,
|
|
289
|
+
);
|
|
283
290
|
logResize("sync-settle", { gsapPos, offset, newX, newY });
|
|
284
291
|
setElementGsapPosition(selection.element, newX, newY);
|
|
285
292
|
});
|
|
@@ -297,6 +304,7 @@ export function useGsapAwareEditing({
|
|
|
297
304
|
makeFetchFallback(selection),
|
|
298
305
|
offset,
|
|
299
306
|
writes.drawKeepingUndone,
|
|
307
|
+
stamp,
|
|
300
308
|
);
|
|
301
309
|
assertGsapEditPersisted(outcome);
|
|
302
310
|
// Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
|
|
@@ -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
|
+
});
|
package/src/utils/elementGsap.ts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
type ElementGsapWindow = Window & {
|
|
9
9
|
gsap?: {
|
|
10
10
|
set?: (target: Element, vars: Record<string, number>) => void;
|
|
11
|
-
getProperty?: (target: Element, prop: string) => unknown;
|
|
12
11
|
};
|
|
13
12
|
};
|
|
14
13
|
|
|
@@ -38,10 +37,12 @@ export function setElementGsapScale(element: HTMLElement, x: number, y: number):
|
|
|
38
37
|
return true;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
/**
|
|
42
|
-
export function
|
|
43
|
-
const
|
|
44
|
-
|
|
40
|
+
/** Set the element's GSAP width/height. Returns false when no runtime is reachable. */
|
|
41
|
+
export function setElementGsapSize(element: HTMLElement, width: number, height: number): boolean {
|
|
42
|
+
const gsap = gsapOf(element);
|
|
43
|
+
if (!gsap?.set) return false;
|
|
44
|
+
gsap.set(element, { width, height });
|
|
45
|
+
return true;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/** The targets CSSPlugin styles: not plain objects (the runtime's filler) or XML-namespace elements. */
|
|
@@ -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
|
+
});
|
|
@@ -17,9 +17,11 @@ const BEFORE = [
|
|
|
17
17
|
const SAMPLES = Array.from({ length: 25 }, (_, i) => i * 0.25);
|
|
18
18
|
|
|
19
19
|
/** Runs a composition script and binds its timeline at `at`, as the preview's runtime does. */
|
|
20
|
-
function play(script: string, at = 0, nested = "") {
|
|
20
|
+
function play(script: string, at = 0, nested = "", registered = "") {
|
|
21
21
|
const win = { __timelines: {} as Record<string, gsap.core.Timeline> };
|
|
22
22
|
new Function("gsap", "window", script)(gsap, win);
|
|
23
|
+
// Another composition's script, which registers its own timeline beside this one.
|
|
24
|
+
new Function("gsap", "window", registered)(gsap, win);
|
|
23
25
|
const timeline = win.__timelines.t!;
|
|
24
26
|
// The runtime nests each sub-composition's timeline into its host's; the script does not know them...
|
|
25
27
|
const sub = gsap.timeline().to({}, { duration: 6.7 });
|
|
@@ -52,12 +54,12 @@ const shownNow = () =>
|
|
|
52
54
|
);
|
|
53
55
|
|
|
54
56
|
/** A preview iframe whose live script is `script`, built and bound. */
|
|
55
|
-
function preview(script: string, nested = "") {
|
|
57
|
+
function preview(script: string, nested = "", registered = "") {
|
|
56
58
|
const tag = document.createElement("script");
|
|
57
59
|
tag.type = "text/plain"; // the runtime already ran it; happy-dom must not run it again
|
|
58
60
|
tag.textContent = script;
|
|
59
61
|
document.body.appendChild(tag);
|
|
60
|
-
const { win, timeline } = play(script, 0, nested);
|
|
62
|
+
const { win, timeline } = play(script, 0, nested, registered);
|
|
61
63
|
const rebind = vi.fn();
|
|
62
64
|
// The runtime hooks a rebind needs; a soft reload would also need `gsap`, which this preview lacks.
|
|
63
65
|
Object.assign(win, { __hfForceTimelineRebind: rebind, __player: { seek: vi.fn() } });
|
|
@@ -245,6 +247,14 @@ it.each([
|
|
|
245
247
|
after: script('tl.to("#a", { x: 100, duration: 1 }, 3);'),
|
|
246
248
|
nested: 'sub.to("#a", { x: 50, duration: 1 }, 2);',
|
|
247
249
|
},
|
|
250
|
+
{
|
|
251
|
+
name: "another composition's timeline on the moved tween's element",
|
|
252
|
+
path: "rerun",
|
|
253
|
+
before: script('tl.to("#a", { x: 100, duration: 1 }, 0);'),
|
|
254
|
+
after: script('tl.to("#a", { x: 100, duration: 1 }, 3);'),
|
|
255
|
+
registered:
|
|
256
|
+
'window.__timelines.scene = gsap.timeline({ paused: true }).to("#a", { x: 50, duration: 1 }, 2);',
|
|
257
|
+
},
|
|
248
258
|
{
|
|
249
259
|
name: "a delayed tween that GSAP orders after the moved one",
|
|
250
260
|
path: "rerun",
|
|
@@ -300,7 +310,7 @@ it.each([
|
|
|
300
310
|
},
|
|
301
311
|
{
|
|
302
312
|
name: "a move of a sub-composition's host",
|
|
303
|
-
path: "
|
|
313
|
+
path: "retime",
|
|
304
314
|
before: script('tl.to("#a", { y: 40, duration: 1 }, 0);'),
|
|
305
315
|
after: script('tl.to("#a", { y: 40, duration: 1 }, 2);'),
|
|
306
316
|
setup: () => {
|
|
@@ -311,17 +321,10 @@ it.each([
|
|
|
311
321
|
},
|
|
312
322
|
{
|
|
313
323
|
name: "a tween on an element inside a sub-composition's host",
|
|
314
|
-
path: "rerun",
|
|
315
|
-
before: script('tl.to("#a", { y: 40, duration: 1 }, 0);'),
|
|
316
|
-
after: script('tl.to("#a", { y: 40, duration: 1 }, 2);'),
|
|
317
|
-
setup: () => inComposition("t", inComposition("scene", document.getElementById("a")!)),
|
|
318
|
-
},
|
|
319
|
-
{
|
|
320
|
-
name: "a tween on an element of the timeline's own composition",
|
|
321
324
|
path: "retime",
|
|
322
325
|
before: script('tl.to("#a", { y: 40, duration: 1 }, 0);'),
|
|
323
326
|
after: script('tl.to("#a", { y: 40, duration: 1 }, 2);'),
|
|
324
|
-
setup: () => inComposition("t", document.getElementById("a")!),
|
|
327
|
+
setup: () => inComposition("t", inComposition("scene", document.getElementById("a")!)),
|
|
325
328
|
},
|
|
326
329
|
{
|
|
327
330
|
name: "a set and a tween on one element that round to the same start",
|
|
@@ -358,10 +361,10 @@ it.each([
|
|
|
358
361
|
},
|
|
359
362
|
])(
|
|
360
363
|
"after a drop over $name, the live preview equals a fresh load or the script re-runs",
|
|
361
|
-
async ({ before, after, nested = "", at = 0, path, setup, unplayed }) => {
|
|
364
|
+
async ({ before, after, nested = "", registered = "", at = 0, path, setup, unplayed }) => {
|
|
362
365
|
setup?.();
|
|
363
366
|
const error = vi.spyOn(console, "error");
|
|
364
|
-
const live = preview(before, nested);
|
|
367
|
+
const live = preview(before, nested, registered);
|
|
365
368
|
// Played through once, then parked: every tween has recorded its start values.
|
|
366
369
|
if (!unplayed) live.timeline.totalTime(live.timeline.duration(), false);
|
|
367
370
|
live.timeline.totalTime(at, false);
|
|
@@ -371,7 +374,7 @@ it.each([
|
|
|
371
374
|
const shown = shownNow();
|
|
372
375
|
const got = observe(live.timeline);
|
|
373
376
|
live.timeline.revert();
|
|
374
|
-
const fresh = play(after, at, nested).timeline;
|
|
377
|
+
const fresh = play(after, at, nested, registered).timeline;
|
|
375
378
|
expect(shown).toEqual(shownNow());
|
|
376
379
|
expect(got).toEqual(observe(fresh));
|
|
377
380
|
}
|
|
@@ -433,6 +436,18 @@ it("reloads the preview, and says so, when moving the live tweens throws", async
|
|
|
433
436
|
expect(error).toHaveBeenCalledTimes(1);
|
|
434
437
|
});
|
|
435
438
|
|
|
439
|
+
it("re-runs a script that does not parse as a classic script, such as one with an import", () => {
|
|
440
|
+
const before = script('import "./setup.js";', 'tl.to("#a", { x: 1, duration: 1 }, 0);');
|
|
441
|
+
const after = script('import "./setup.js";', 'tl.to("#a", { x: 1, duration: 1 }, 1);');
|
|
442
|
+
expect(planLiveRetime(before, after).kind).toBe("rerun");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("re-runs the script when the edit also flips an operator in a tween's value", () => {
|
|
446
|
+
const before = script('tl.to("#a", { x: window.innerWidth - 40, duration: 1 }, 0);');
|
|
447
|
+
const after = script('tl.to("#a", { x: window.innerWidth + 40, duration: 1 }, 1);');
|
|
448
|
+
expect(planLiveRetime(before, after).kind).toBe("rerun");
|
|
449
|
+
});
|
|
450
|
+
|
|
436
451
|
it("re-runs the script when the edit also joins two words of code", () => {
|
|
437
452
|
const before = script('tl.to("#a", { x: 1, duration: 1 }, 0);', "var b = typeof a;");
|
|
438
453
|
const after = script('tl.to("#a", { x: 1, duration: 1 }, 1);', "var b = typeofa;");
|
|
@@ -514,6 +529,15 @@ it("syncs a timeline move by moving the live tweens and rebinding, without re-ru
|
|
|
514
529
|
expect(document.querySelectorAll("script")).toHaveLength(1);
|
|
515
530
|
});
|
|
516
531
|
|
|
532
|
+
it("moves the live tweens when the preview runs the saved script re-printed without its comments", async () => {
|
|
533
|
+
const live = preview(BEFORE);
|
|
534
|
+
const saved = `// Seams between sections.\n${moveA().replaceAll('"', "'")}\n/* end */`;
|
|
535
|
+
|
|
536
|
+
const reloadPreview = await dropInto(live.iframe, saved);
|
|
537
|
+
|
|
538
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
539
|
+
});
|
|
540
|
+
|
|
517
541
|
it("moves the live tweens when the preview runs a reformatted copy of the saved script", async () => {
|
|
518
542
|
const live = preview(BEFORE);
|
|
519
543
|
live.tag.textContent = reformatted(BEFORE);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
-
import { parseGsapScriptAcorn } from "@hyperframes/
|
|
2
|
+
import { parseGsapScriptAcorn, scriptShape } from "@hyperframes/parsers/gsap-parser-acorn";
|
|
3
3
|
import { hasExplicitTime } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
4
4
|
import { RUNTIME_FILLER } from "@hyperframes/core/runtime/protocol";
|
|
5
5
|
import {
|
|
@@ -30,19 +30,14 @@ export type LiveRetimePlan =
|
|
|
30
30
|
|
|
31
31
|
const RERUN = { kind: "rerun" } as const;
|
|
32
32
|
|
|
33
|
-
/** The preview runs a reformatted copy of the script: compare code, not its layout. */
|
|
34
|
-
const withoutLayout = (code: string) =>
|
|
35
|
-
code
|
|
36
|
-
.replace(/\s+/g, " ")
|
|
37
|
-
.replace(/ ?([^\w$ ]) ?/g, "$1")
|
|
38
|
-
.trim();
|
|
39
|
-
|
|
40
33
|
function untimedShape(animation: GsapAnimation): string {
|
|
41
34
|
const { id: _id, position: _p, resolvedStart: _s, duration: _d, ...rest } = animation;
|
|
42
35
|
// An offset into the text, which moves when the preview reformats its copy of the script.
|
|
43
36
|
const provenance = rest.provenance && { ...rest.provenance, sourceRange: undefined };
|
|
44
37
|
return JSON.stringify({ ...rest, provenance }, (_key, value) =>
|
|
45
|
-
typeof value === "string" && value.startsWith("__raw:")
|
|
38
|
+
typeof value === "string" && value.startsWith("__raw:")
|
|
39
|
+
? (scriptShape(`(${value.slice(6)}\n)`) ?? value)
|
|
40
|
+
: value,
|
|
46
41
|
);
|
|
47
42
|
}
|
|
48
43
|
|
|
@@ -55,13 +50,22 @@ const placedAlone = (animation: GsapAnimation) =>
|
|
|
55
50
|
const lengthOf = (animation: GsapAnimation) =>
|
|
56
51
|
animation.method === "set" ? 0 : animation.durationUnresolved ? undefined : animation.duration;
|
|
57
52
|
|
|
58
|
-
/**
|
|
59
|
-
const
|
|
60
|
-
|
|
53
|
+
/** The last two script shapes, by text: a repeat drag starts from the script the last one saved. */
|
|
54
|
+
const shapes = new Map<string, string | null>();
|
|
55
|
+
|
|
56
|
+
function shapeOf(code: string): string | null {
|
|
57
|
+
const shape = shapes.has(code) ? (shapes.get(code) as string | null) : scriptShape(code, true);
|
|
58
|
+
shapes.delete(code);
|
|
59
|
+
shapes.set(code, shape);
|
|
60
|
+
if (shapes.size > 2) shapes.delete(shapes.keys().next().value!);
|
|
61
|
+
return shape;
|
|
62
|
+
}
|
|
61
63
|
|
|
62
64
|
/** Decides, from the two scripts alone, whether a saved edit moved tweens and nothing else. */
|
|
63
65
|
export function planLiveRetime(before: string, after: string): LiveRetimePlan {
|
|
64
|
-
|
|
66
|
+
// The preview runs the script re-printed by the bundler; a timing edit rewrites numbers only.
|
|
67
|
+
const shape = shapeOf(before);
|
|
68
|
+
if (shape === null || shape !== shapeOf(after)) return RERUN;
|
|
65
69
|
const keys = timelineKeys(after);
|
|
66
70
|
if (keys.length !== 1 || timelineKeys(before).join() !== keys.join()) return RERUN;
|
|
67
71
|
const was = parseGsapScriptAcorn(before).animations.filter(onTimeline);
|
|
@@ -180,16 +184,6 @@ function sharesOneStretch(tweens: TweenTiming[]): boolean {
|
|
|
180
184
|
return stretch > 0 && fits;
|
|
181
185
|
}
|
|
182
186
|
|
|
183
|
-
/** The runtime places a sub-composition's timeline at its host's start once; moving the host leaves it there. */
|
|
184
|
-
function inOrHostsSubComposition(target: unknown, ownId: string): boolean {
|
|
185
|
-
const element = target as Element;
|
|
186
|
-
const owner = element.closest?.("[data-composition-id]");
|
|
187
|
-
return (
|
|
188
|
-
(owner != null && owner.getAttribute("data-composition-id") !== ownId) ||
|
|
189
|
-
element.querySelector?.("[data-composition-id]") != null
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
187
|
/** Tweens on one element still start in the order they did, an equal start in script order. */
|
|
194
188
|
const keepsOrder = (tweens: TweenTiming[]) =>
|
|
195
189
|
tweens.every(
|
|
@@ -204,6 +198,7 @@ function movesKeepEachElementsHistory(
|
|
|
204
198
|
timeline: LiveTimeline,
|
|
205
199
|
plan: LiveRetime,
|
|
206
200
|
children: LiveTween[],
|
|
201
|
+
registry: unknown[],
|
|
207
202
|
): boolean {
|
|
208
203
|
const byElement = new Map<unknown, number[]>();
|
|
209
204
|
children.forEach((child, i) => {
|
|
@@ -216,7 +211,6 @@ function movesKeepEachElementsHistory(
|
|
|
216
211
|
const moved = new Set<unknown>();
|
|
217
212
|
for (const [target, indexes] of byElement) {
|
|
218
213
|
if (!indexes.some((i) => plan.tweens[i]!.moved)) continue;
|
|
219
|
-
if (inOrHostsSubComposition(target, plan.key)) return false;
|
|
220
214
|
const tweens = indexes.map((i) => plan.tweens[i]!);
|
|
221
215
|
// GSAP adds delays to the schedule unstretched; a shift keeps every length.
|
|
222
216
|
const shifted = tweens.every((t) => t.duration === t.wasDuration);
|
|
@@ -224,13 +218,26 @@ function movesKeepEachElementsHistory(
|
|
|
224
218
|
if (!keepsOrder(tweens) || !sharesOneStretch(tweens) || !(shifted || undelayed)) return false;
|
|
225
219
|
moved.add(target);
|
|
226
220
|
}
|
|
227
|
-
return !timeline
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
221
|
+
return !animatedElsewhere(timeline, registry, moved);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Whether a timeline other than `own` (nested in it, or another registered one) animates any of `targets`. */
|
|
225
|
+
function animatedElsewhere(own: LiveTimeline, registry: unknown[], targets: Set<unknown>): boolean {
|
|
226
|
+
const seen = new Set<unknown>([own]);
|
|
227
|
+
const visit = (timeline: LiveTimeline): boolean => {
|
|
228
|
+
if (seen.has(timeline)) return false;
|
|
229
|
+
seen.add(timeline);
|
|
230
|
+
return (
|
|
231
|
+
timeline
|
|
232
|
+
.getChildren(false, true, false)
|
|
233
|
+
.some((tween) => tween.targets?.().some((target) => targets.has(target))) ||
|
|
234
|
+
timeline.getChildren(false, false, true).some(visit)
|
|
233
235
|
);
|
|
236
|
+
};
|
|
237
|
+
const timelines = registry.filter(
|
|
238
|
+
(entry): entry is LiveTimeline => typeof (entry as LiveTimeline)?.getChildren === "function",
|
|
239
|
+
);
|
|
240
|
+
return own.getChildren(false, false, true).some(visit) || timelines.some(visit);
|
|
234
241
|
}
|
|
235
242
|
|
|
236
243
|
/**
|
|
@@ -245,7 +252,8 @@ export function applyLiveRetime(iframe: HTMLIFrameElement | null, plan: LiveReti
|
|
|
245
252
|
const children = pairedLiveTweens(timeline, plan);
|
|
246
253
|
if (!children) return false;
|
|
247
254
|
if (plan.tweens.some((t) => t.moved)) {
|
|
248
|
-
|
|
255
|
+
const registry = Object.values(win?.__timelines ?? {});
|
|
256
|
+
if (!movesKeepEachElementsHistory(timeline, plan, children, registry)) return false;
|
|
249
257
|
// Re-added in script order: GSAP orders equal starts by when they were added, as a fresh run does.
|
|
250
258
|
const bySource = plan.tweens
|
|
251
259
|
.map((t, i) => ({ ...t, tween: children[i]! }))
|