@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.
Files changed (54) hide show
  1. package/dist/assets/{hyperframes-player-Bro1gj0Z.js → hyperframes-player-ukvUFqdg.js} +1 -1
  2. package/dist/assets/{index-DnVGIbEC.js → index-BY_myArg.js} +1 -1
  3. package/dist/assets/{index-wnXyQjW-.js → index-C-pD9_xY.js} +185 -184
  4. package/dist/assets/{index-BwNDTmAD.js → index-CRRgIxsS.js} +1 -1
  5. package/dist/assets/{index-D11atkBt.css → index-syMK0_9C.css} +1 -1
  6. package/dist/{chunk-5VSXRGIX.js → chunk-NM7BXNZ7.js} +1 -1
  7. package/dist/chunk-NM7BXNZ7.js.map +1 -0
  8. package/dist/{chunk-FOEID72M.js → chunk-QRLKWENQ.js} +2 -2
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +227 -88
  12. package/dist/index.js.map +1 -1
  13. package/dist/player.js +2 -2
  14. package/dist/styles.css +1 -1
  15. package/dist/ui.d.ts +3 -2
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/components/editor/AnimationCard.test.tsx +60 -0
  19. package/src/components/editor/AnimationCard.tsx +16 -6
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -1
  21. package/src/components/editor/LayersPanel.tsx +39 -47
  22. package/src/components/editor/domEditInlineText.ts +1 -1
  23. package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
  24. package/src/components/editor/gsapAnimationHelpers.ts +34 -0
  25. package/src/components/editor/layerKind.test.ts +38 -0
  26. package/src/components/editor/layerKind.ts +19 -0
  27. package/src/components/editor/useDomEditNudge.test.tsx +48 -27
  28. package/src/components/ui/cn.ts +2 -1
  29. package/src/contexts/DomEditContext.tsx +3 -1
  30. package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
  31. package/src/hooks/gsapRuntimePatch.ts +4 -0
  32. package/src/hooks/gsapShared.test.ts +11 -0
  33. package/src/hooks/gsapShared.ts +1 -1
  34. package/src/hooks/keyframeEase.realGsap.test.ts +37 -0
  35. package/src/hooks/useAnimatedPropertyCommit.test.tsx +33 -0
  36. package/src/hooks/useAnimatedPropertyCommit.ts +3 -1
  37. package/src/hooks/useDomGeometryCommits.test.tsx +21 -0
  38. package/src/hooks/useDomGeometryCommits.ts +6 -1
  39. package/src/hooks/useGestureCommit.test.tsx +89 -0
  40. package/src/hooks/useGestureCommit.ts +26 -9
  41. package/src/icons/SystemIcons.tsx +6 -0
  42. package/src/layersPanelExport.test.tsx +45 -4
  43. package/src/utils/gsapKeyframeEases.ts +2 -0
  44. package/src/utils/gsapLiveRetime.cache.test.ts +39 -0
  45. package/src/utils/gsapLiveRetime.realGsap.test.ts +39 -15
  46. package/src/utils/gsapLiveRetime.ts +39 -31
  47. package/src/utils/gsapSoftReload.test.ts +98 -14
  48. package/src/utils/gsapSoftReload.ts +14 -2
  49. package/src/utils/gsapUndoRestore.test.ts +21 -2
  50. package/src/utils/softReloadTargets.ts +56 -0
  51. package/src/utils/studioPendingEdits.test.ts +123 -0
  52. package/src/utils/studioPendingEdits.ts +27 -1
  53. package/dist/chunk-5VSXRGIX.js.map +0 -1
  54. /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: "rerun",
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/core/gsap-parser-acorn";
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:") ? withoutLayout(value) : value,
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
- /** A timing edit rewrites numbers only; anything else may be code the parser does not read. */
59
- const withoutNumbers = (script: string) =>
60
- withoutLayout(script.replace(/(?<![\w$#.])\d*\.?\d+/g, "0"));
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
- if (withoutNumbers(before) !== withoutNumbers(after)) return RERUN;
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
- .getChildren(false, false, true)
229
- .some((sub) =>
230
- sub
231
- .getChildren(true, true, false)
232
- .some((tween) => tween.targets?.().some((t) => moved.has(t))),
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
- if (!movesKeepEachElementsHistory(timeline, plan, children)) return false;
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
- const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set: vi.fn() } });
140
- (iframe as unknown as { contentDocument: unknown }).contentDocument = {
141
- querySelectorAll: (sel: string) =>
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 { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets";
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(win, doc, targetKeys);
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 = (live: string, authored: string, edit: string) => {
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(