@hyperframes/studio 0.8.134 → 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-Bro1gj0Z.js → hyperframes-player-DFv9heE6.js} +1 -1
- package/dist/assets/{index-DnVGIbEC.js → index-Buy06mZy.js} +1 -1
- package/dist/assets/{index-BwNDTmAD.js → index-CTp4loy9.js} +1 -1
- package/dist/assets/{index-wnXyQjW-.js → index-N_4Ij4X6.js} +185 -184
- 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
|
@@ -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]! }))
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication
|
|
1
2
|
// @vitest-environment happy-dom
|
|
2
3
|
|
|
4
|
+
import { patchRuntimeTweenInPlace } from "../hooks/gsapRuntimePatch";
|
|
3
5
|
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
4
6
|
import {
|
|
5
7
|
readNestedFiles,
|
|
@@ -70,6 +72,29 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
|
|
|
70
72
|
};
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
/** A mock iframe whose document holds one GSAP script and one composition root; `byId` answers id selectors. */
|
|
76
|
+
function iframeWithComposition(script: string, root: Element, byId: Record<string, Element> = {}) {
|
|
77
|
+
const scriptEl = document.createElement("script");
|
|
78
|
+
scriptEl.textContent = script;
|
|
79
|
+
const container = document.createElement("div");
|
|
80
|
+
container.appendChild(scriptEl);
|
|
81
|
+
const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set: vi.fn() } });
|
|
82
|
+
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
|
|
83
|
+
querySelectorAll: (sel: string) =>
|
|
84
|
+
sel === "script:not([src])"
|
|
85
|
+
? [scriptEl]
|
|
86
|
+
: sel.includes("composition-id")
|
|
87
|
+
? [root]
|
|
88
|
+
: byId[sel]
|
|
89
|
+
? [byId[sel]]
|
|
90
|
+
: [],
|
|
91
|
+
createElement: (tag: string) => document.createElement(tag),
|
|
92
|
+
body: container,
|
|
93
|
+
head: document.createElement("div"),
|
|
94
|
+
};
|
|
95
|
+
return iframe;
|
|
96
|
+
}
|
|
97
|
+
|
|
73
98
|
describe("applySoftReload", () => {
|
|
74
99
|
it('returns "cannot-soft-reload" when iframe is null', () => {
|
|
75
100
|
expect(applySoftReload(null, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
|
@@ -127,23 +152,13 @@ describe("applySoftReload", () => {
|
|
|
127
152
|
orphan.style.cssText = "left: 1240px; top: 200px; transform: translate(449px, 0px)";
|
|
128
153
|
Object.assign(orphan, { _gsap: {} }); // GSAP cache marker (set by gsap.set)
|
|
129
154
|
|
|
130
|
-
const scriptEl = document.createElement("script");
|
|
131
|
-
scriptEl.textContent = 'const tl = gsap.timeline({ paused: true }); tl.to("#x", { x: 1 });';
|
|
132
|
-
const container = document.createElement("div");
|
|
133
|
-
container.appendChild(scriptEl);
|
|
134
|
-
|
|
135
155
|
const root = document.createElement("div");
|
|
136
156
|
root.setAttribute("data-composition-id", "root");
|
|
137
157
|
root.appendChild(orphan);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
sel === "script:not([src])" ? [scriptEl] : sel.includes("composition-id") ? [root] : [],
|
|
143
|
-
createElement: (tag: string) => document.createElement(tag),
|
|
144
|
-
body: container,
|
|
145
|
-
head: document.createElement("div"),
|
|
146
|
-
};
|
|
158
|
+
const iframe = iframeWithComposition(
|
|
159
|
+
'const tl = gsap.timeline({ paused: true }); tl.to("#x", { x: 1 });',
|
|
160
|
+
root,
|
|
161
|
+
);
|
|
147
162
|
|
|
148
163
|
applySoftReload(iframe, SCRIPT_TEXT);
|
|
149
164
|
|
|
@@ -151,6 +166,37 @@ describe("applySoftReload", () => {
|
|
|
151
166
|
expect(orphan.style.left).toBe("1240px"); // authored CSS base preserved
|
|
152
167
|
});
|
|
153
168
|
|
|
169
|
+
it("clears what a standalone gsap.set wrote once the new script no longer sets it", () => {
|
|
170
|
+
// An undo of a Design-panel W edit on an animated box removes its gsap.set width.
|
|
171
|
+
const target = document.createElement("div");
|
|
172
|
+
target.id = "target";
|
|
173
|
+
target.style.cssText = "left: 10px; width: 300px";
|
|
174
|
+
Object.assign(target, { _gsap: {} });
|
|
175
|
+
const root = document.createElement("div");
|
|
176
|
+
root.setAttribute("data-composition-id", "root");
|
|
177
|
+
root.appendChild(target);
|
|
178
|
+
const iframe = iframeWithComposition(
|
|
179
|
+
`window.__timelines = window.__timelines || {};
|
|
180
|
+
const tl = gsap.timeline({ paused: true });
|
|
181
|
+
tl.to("#target", { x: 100 });
|
|
182
|
+
gsap.set("#target", { width: 300 });
|
|
183
|
+
window.__timelines["root"] = tl;`,
|
|
184
|
+
root,
|
|
185
|
+
{ "#target": target },
|
|
186
|
+
);
|
|
187
|
+
const restored = `window.__timelines = window.__timelines || {};
|
|
188
|
+
const tl = gsap.timeline({ paused: true });
|
|
189
|
+
tl.to("#target", { x: 100 });
|
|
190
|
+
window.__timelines["root"] = tl;`;
|
|
191
|
+
|
|
192
|
+
applySoftReload(iframe, restored, {
|
|
193
|
+
authoredHtml: `<html><body><div data-composition-id="root"><div id="target" style="left: 10px"></div></div><script>${restored}</script></body></html>`,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(target.style.width).toBe("");
|
|
197
|
+
expect(target.style.left).toBe("10px");
|
|
198
|
+
});
|
|
199
|
+
|
|
154
200
|
it("wraps execution in __hfSuppressSceneMutations when available", () => {
|
|
155
201
|
let suppressionCalled = false;
|
|
156
202
|
const { iframe } = buildMockIframe({
|
|
@@ -917,3 +963,41 @@ describe("applySoftReload over a composition's own markup", () => {
|
|
|
917
963
|
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
|
918
964
|
});
|
|
919
965
|
});
|
|
966
|
+
|
|
967
|
+
describe("a gsap.set a live patch applied", () => {
|
|
968
|
+
it("is cleared by the next soft reload once the new script no longer sets it, and only once", () => {
|
|
969
|
+
const markup = `<div data-composition-id="root"><div id="a" style="left: 10px"></div></div>`;
|
|
970
|
+
const doc = document.implementation.createHTMLDocument("");
|
|
971
|
+
doc.body.innerHTML = `${markup}<script>window.__timelines["root"]=gsap.timeline();</script>`;
|
|
972
|
+
const set = (target: HTMLElement | HTMLElement[], vars: Record<string, unknown>) => {
|
|
973
|
+
for (const el of [target].flat()) {
|
|
974
|
+
if (vars.clearProps) el.removeAttribute("style");
|
|
975
|
+
else for (const [k, v] of Object.entries(vars)) el.style.setProperty(k, `${v}px`);
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
const iframe = {
|
|
979
|
+
contentDocument: doc,
|
|
980
|
+
contentWindow: {
|
|
981
|
+
gsap: { timeline: () => {}, set },
|
|
982
|
+
__hfForceTimelineRebind: () => {},
|
|
983
|
+
__timelines: {},
|
|
984
|
+
__player: { getTime: () => 0, seek: () => {} },
|
|
985
|
+
},
|
|
986
|
+
} as unknown as HTMLIFrameElement;
|
|
987
|
+
const el = doc.getElementById("a")!;
|
|
988
|
+
const reload = () => {
|
|
989
|
+
const script = `window.__timelines["root"]=gsap.timeline();gsap.set("#a",{width:300});`;
|
|
990
|
+
applySoftReload(iframe, script, { authoredHtml: `${markup}<script>${script}</script>` });
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
// W 300, then H 200, then a commit that removes height from the set.
|
|
994
|
+
patchRuntimeTweenInPlace(iframe, "#a", { kind: "global-set", props: { width: 300 } });
|
|
995
|
+
patchRuntimeTweenInPlace(iframe, "#a", { kind: "global-set", props: { height: 200 } });
|
|
996
|
+
reload();
|
|
997
|
+
expect([el.style.height, el.style.left]).toEqual(["", "10px"]);
|
|
998
|
+
|
|
999
|
+
el.style.height = "50px";
|
|
1000
|
+
reload();
|
|
1001
|
+
expect(el.style.height).toBe("50px");
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
@@ -2,7 +2,13 @@ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-gradin
|
|
|
2
2
|
import { motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
|
|
3
3
|
import { findAuthoredElement } from "./authoredSource";
|
|
4
4
|
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
authoringFile,
|
|
7
|
+
collectResetTargets,
|
|
8
|
+
compositionFile,
|
|
9
|
+
fileDocs,
|
|
10
|
+
forgetLiveSets,
|
|
11
|
+
} from "./softReloadTargets";
|
|
6
12
|
|
|
7
13
|
type IframeWindow = Window & {
|
|
8
14
|
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
|
|
@@ -370,7 +376,12 @@ export function applySoftReload(
|
|
|
370
376
|
// fallow-ignore-next-line complexity
|
|
371
377
|
const doReload = () => {
|
|
372
378
|
const timelines = win.__timelines;
|
|
373
|
-
const targets = collectResetTargets(
|
|
379
|
+
const targets = collectResetTargets(
|
|
380
|
+
win,
|
|
381
|
+
doc,
|
|
382
|
+
targetKeys,
|
|
383
|
+
staleScripts.map((script) => script.textContent ?? ""),
|
|
384
|
+
);
|
|
374
385
|
|
|
375
386
|
// Kill ONLY the target composition's timeline(s) — leaving every other
|
|
376
387
|
// composition's timeline (and its children on the global timeline) intact.
|
|
@@ -423,6 +434,7 @@ export function applySoftReload(
|
|
|
423
434
|
}
|
|
424
435
|
}
|
|
425
436
|
}
|
|
437
|
+
forgetLiveSets(targets);
|
|
426
438
|
|
|
427
439
|
for (const script of staleScripts) script.remove();
|
|
428
440
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
diffSoftReloadableRestore,
|
|
20
20
|
showRestoreInPlace,
|
|
21
21
|
} from "./gsapUndoRestore";
|
|
22
|
+
import { recordLiveSet } from "./softReloadTargets";
|
|
22
23
|
import { applyPatch } from "./sourcePatcher";
|
|
23
24
|
import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
|
|
24
25
|
import { studioManualEditSavesIn } from "../components/editor/manualEditsDom";
|
|
@@ -215,10 +216,16 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
215
216
|
describe("an undo that re-runs a changed script matches a fresh load of the restored file", () => {
|
|
216
217
|
const script = (extra: string) => `window.__timelines["root"]=gsap.timeline();${extra}`;
|
|
217
218
|
const root = (body: string) => `<div data-composition-id="root">${body}</div>`;
|
|
218
|
-
const undoScriptEdit = (
|
|
219
|
+
const undoScriptEdit = (
|
|
220
|
+
live: string,
|
|
221
|
+
authored: string,
|
|
222
|
+
edit: string,
|
|
223
|
+
patchedLive?: (doc: Document) => void,
|
|
224
|
+
) => {
|
|
219
225
|
const { iframe, contentWindow, doc } = buildLiveIframe(
|
|
220
|
-
`${root(live)}<script>${script(edit)}</script>`,
|
|
226
|
+
`${root(live)}<script>${script(patchedLive ? "" : edit)}</script>`,
|
|
221
227
|
);
|
|
228
|
+
patchedLive?.(doc);
|
|
222
229
|
const clearProps = (targets: HTMLElement[]) =>
|
|
223
230
|
targets.forEach((t) => t.removeAttribute("style"));
|
|
224
231
|
Object.assign(contentWindow.gsap, { set: clearProps });
|
|
@@ -243,6 +250,18 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
243
250
|
expect(doc.getElementById("a")!.getAttribute("style")).toBe("width: 300px");
|
|
244
251
|
});
|
|
245
252
|
|
|
253
|
+
it("drops the width a W edit set live, though the preview never ran its gsap.set", () => {
|
|
254
|
+
// The Design panel applies W to the live element and writes the set only to the file.
|
|
255
|
+
const doc = undoScriptEdit(
|
|
256
|
+
`<div id="a" style="left: 10px; width: 300px">t</div>`,
|
|
257
|
+
`<div id="a" style="left: 10px">t</div>`,
|
|
258
|
+
`gsap.set("#a",{width:300});`,
|
|
259
|
+
(live) => recordLiveSet(live.getElementById("a")!, { width: 300 }),
|
|
260
|
+
);
|
|
261
|
+
const { style } = doc.getElementById("a")!;
|
|
262
|
+
expect([style.width, style.left]).toEqual(["", "10px"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
246
265
|
it("keeps the authored inline rotation of an element GSAP moved", () => {
|
|
247
266
|
const doc = undoScriptEdit(
|
|
248
267
|
`<div id="r" style="left: 10px; transform: translate(116px, 72px) rotate(20deg)">t</div>`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Which elements a GSAP soft reload resets, what their tweens wrote, and which file writes each.
|
|
2
|
+
import { parseGsapScript } from "@hyperframes/parsers/gsap-parser";
|
|
2
3
|
import { parseSavedSource } from "./authoredSource";
|
|
3
4
|
import { elementTargets } from "./elementGsap";
|
|
4
5
|
|
|
@@ -49,6 +50,7 @@ export function collectResetTargets(
|
|
|
49
50
|
win: { __timelines?: Record<string, unknown> },
|
|
50
51
|
doc: Document,
|
|
51
52
|
targetKeys: string[],
|
|
53
|
+
outgoingScripts: readonly string[] = [],
|
|
52
54
|
): Map<Element, Set<string>> {
|
|
53
55
|
const targets = new Map<Element, Set<string>>();
|
|
54
56
|
const timelines = (win.__timelines ?? {}) as Record<string, TweenLike | undefined>;
|
|
@@ -61,6 +63,8 @@ export function collectResetTargets(
|
|
|
61
63
|
} catch {}
|
|
62
64
|
}
|
|
63
65
|
sweepHeldElements(doc, targetKeys, others, targets);
|
|
66
|
+
addLiveSetTargets(doc, targetKeys, targets);
|
|
67
|
+
for (const script of outgoingScripts) addStandaloneSetTargets(doc, script, targets);
|
|
64
68
|
return targets;
|
|
65
69
|
}
|
|
66
70
|
|
|
@@ -105,6 +109,58 @@ function sweepHeldElements(
|
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
// What a live patch set on each element since its last reload; no script in the preview ran it.
|
|
113
|
+
const liveSets = new WeakMap<Document, Map<Element, Set<string>>>();
|
|
114
|
+
|
|
115
|
+
export function recordLiveSet(el: Element, vars: Record<string, unknown>): void {
|
|
116
|
+
const byElement = liveSets.get(el.ownerDocument) ?? new Map<Element, Set<string>>();
|
|
117
|
+
liveSets.set(el.ownerDocument, byElement);
|
|
118
|
+
const props = byElement.get(el) ?? new Set<string>();
|
|
119
|
+
for (const prop of tweenedProps(vars)) props.add(prop);
|
|
120
|
+
byElement.set(el, props);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function addLiveSetTargets(
|
|
124
|
+
doc: Document,
|
|
125
|
+
targetKeys: string[],
|
|
126
|
+
targets: Map<Element, Set<string>>,
|
|
127
|
+
): void {
|
|
128
|
+
const roots = new Set<Element | null | undefined>(targetKeys.map((k) => compositionRoot(doc, k)));
|
|
129
|
+
for (const [el, props] of liveSets.get(doc) ?? []) {
|
|
130
|
+
if (!roots.has(el) && !roots.has(el.parentElement?.closest("[data-composition-id]"))) continue;
|
|
131
|
+
const seen = targets.get(el) ?? new Set<string>();
|
|
132
|
+
for (const prop of props) seen.add(prop);
|
|
133
|
+
targets.set(el, seen);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function forgetLiveSets(reset: Map<Element, unknown>): void {
|
|
138
|
+
for (const el of reset.keys()) liveSets.get(el.ownerDocument)?.delete(el);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Adds what each standalone `gsap.set` in the outgoing script wrote, which no timeline child records. */
|
|
142
|
+
function addStandaloneSetTargets(
|
|
143
|
+
doc: Document,
|
|
144
|
+
outgoingScript: string,
|
|
145
|
+
targets: Map<Element, Set<string>>,
|
|
146
|
+
): void {
|
|
147
|
+
if (!outgoingScript.includes("gsap.set(")) return;
|
|
148
|
+
for (const set of parseGsapScript(outgoingScript).animations) {
|
|
149
|
+
if (set.method !== "set" || !set.global) continue;
|
|
150
|
+
let els: Element[];
|
|
151
|
+
try {
|
|
152
|
+
els = [...doc.querySelectorAll(set.targetSelector)];
|
|
153
|
+
} catch {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
for (const el of els) {
|
|
157
|
+
const seen = targets.get(el) ?? new Set<string>();
|
|
158
|
+
for (const prop of tweenedProps(set.properties)) seen.add(prop);
|
|
159
|
+
targets.set(el, seen);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
108
164
|
// Each file a reset element may be written in, parsed at most once per reload: the reloaded
|
|
109
165
|
// composition's own file is the one just written, any other comes from the caller.
|
|
110
166
|
export function fileDocs(
|