@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.
Files changed (72) hide show
  1. package/dist/assets/{hyperframes-player-Cjcwveg0.js → hyperframes-player-DFv9heE6.js} +1 -1
  2. package/dist/assets/{index-CwgWmfgx.js → index-Buy06mZy.js} +1 -1
  3. package/dist/assets/{index-ME-Z5jh6.js → index-CTp4loy9.js} +1 -1
  4. package/dist/assets/{index-Dj21zXWA.js → index-N_4Ij4X6.js} +184 -183
  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 +911 -691
  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/manualEditsDom.ts +5 -1
  28. package/src/components/editor/plainTranslate.test.ts +21 -2
  29. package/src/components/editor/plainTranslate.ts +7 -0
  30. package/src/components/editor/useDomEditNudge.test.tsx +48 -27
  31. package/src/components/ui/cn.ts +2 -1
  32. package/src/contexts/DomEditContext.tsx +3 -1
  33. package/src/hooks/draggedGsapPosition.test.ts +34 -0
  34. package/src/hooks/draggedGsapPosition.ts +40 -15
  35. package/src/hooks/gsapDragCommit.ts +17 -10
  36. package/src/hooks/gsapDragPositionCommit.ts +8 -9
  37. package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
  38. package/src/hooks/gsapResizeIntercept.test.ts +72 -1
  39. package/src/hooks/gsapResizeIntercept.ts +84 -17
  40. package/src/hooks/gsapRuntimeBridge.ts +4 -1
  41. package/src/hooks/gsapRuntimePatch.ts +4 -0
  42. package/src/hooks/gsapShared.test.ts +11 -0
  43. package/src/hooks/gsapShared.ts +1 -1
  44. package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
  45. package/src/hooks/keyframeEase.realGsap.test.ts +37 -0
  46. package/src/hooks/useAnimatedPropertyCommit.test.tsx +33 -0
  47. package/src/hooks/useAnimatedPropertyCommit.ts +3 -1
  48. package/src/hooks/useDomGeometryCommits.test.tsx +21 -0
  49. package/src/hooks/useDomGeometryCommits.ts +6 -1
  50. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  51. package/src/hooks/useEditHistoryActions.ts +3 -0
  52. package/src/hooks/useEnableKeyframes.ts +32 -37
  53. package/src/hooks/useGestureCommit.test.tsx +89 -0
  54. package/src/hooks/useGestureCommit.ts +26 -9
  55. package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
  56. package/src/hooks/useGsapAwareEditing.ts +10 -2
  57. package/src/icons/SystemIcons.tsx +6 -0
  58. package/src/layersPanelExport.test.tsx +45 -4
  59. package/src/utils/elementGsap.ts +6 -5
  60. package/src/utils/gsapKeyframeEases.ts +2 -0
  61. package/src/utils/gsapLiveRetime.cache.test.ts +39 -0
  62. package/src/utils/gsapLiveRetime.realGsap.test.ts +39 -15
  63. package/src/utils/gsapLiveRetime.ts +39 -31
  64. package/src/utils/gsapSoftReload.test.ts +98 -14
  65. package/src/utils/gsapSoftReload.ts +14 -2
  66. package/src/utils/gsapUndoRestore.test.ts +43 -2
  67. package/src/utils/gsapUndoRestore.ts +4 -2
  68. package/src/utils/softReloadTargets.ts +56 -0
  69. package/src/utils/studioPendingEdits.test.ts +123 -0
  70. package/src/utils/studioPendingEdits.ts +39 -3
  71. package/dist/chunk-5VSXRGIX.js.map +0 -1
  72. /package/dist/{chunk-FOEID72M.js.map → chunk-QRLKWENQ.js.map} +0 -0
@@ -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,8 +19,10 @@ 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";
25
+ import { studioManualEditSavesIn } from "../components/editor/manualEditsDom";
24
26
  import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate";
25
27
 
26
28
  // ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
@@ -214,10 +216,16 @@ describe("applyUndoRestoreToPreview", () => {
214
216
  describe("an undo that re-runs a changed script matches a fresh load of the restored file", () => {
215
217
  const script = (extra: string) => `window.__timelines["root"]=gsap.timeline();${extra}`;
216
218
  const root = (body: string) => `<div data-composition-id="root">${body}</div>`;
217
- 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
+ ) => {
218
225
  const { iframe, contentWindow, doc } = buildLiveIframe(
219
- `${root(live)}<script>${script(edit)}</script>`,
226
+ `${root(live)}<script>${script(patchedLive ? "" : edit)}</script>`,
220
227
  );
228
+ patchedLive?.(doc);
221
229
  const clearProps = (targets: HTMLElement[]) =>
222
230
  targets.forEach((t) => t.removeAttribute("style"));
223
231
  Object.assign(contentWindow.gsap, { set: clearProps });
@@ -242,6 +250,18 @@ describe("applyUndoRestoreToPreview", () => {
242
250
  expect(doc.getElementById("a")!.getAttribute("style")).toBe("width: 300px");
243
251
  });
244
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
+
245
265
  it("keeps the authored inline rotation of an element GSAP moved", () => {
246
266
  const doc = undoScriptEdit(
247
267
  `<div id="r" style="left: 10px; transform: translate(116px, 72px) rotate(20deg)">t</div>`,
@@ -281,6 +301,16 @@ describe("applyUndoRestoreToPreview", () => {
281
301
  expect(doc.getElementById("a")!.getAttribute("style")).toBe("z-index: 8");
282
302
  });
283
303
 
304
+ it("counts a script-only restore applied in place, so a reload requested before it loads again", () => {
305
+ const script = (x: number) => `window.__timelines["root"]=gsap.timeline().to("#a",{x:${x}});`;
306
+ const page = (x: number) => `<div id="a">t</div><script>${script(x)}</script>`;
307
+ const { iframe, doc } = buildLiveIframe(page(2));
308
+ const before = studioManualEditSavesIn(doc);
309
+ const files = { [ROOT]: { previous: wrap(page(2)), restored: wrap(page(1)) } };
310
+ applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
311
+ expect(studioManualEditSavesIn(doc)).toBeGreaterThan(before);
312
+ });
313
+
284
314
  it("does NOT re-run an UNCHANGED GSAP script for an attribute-only restore", () => {
285
315
  // The live doc holds a script element; a re-run would mutate/remove it
286
316
  // (applySoftReload removes stale script elements before re-running).
@@ -563,6 +593,17 @@ describe("an undo that lands while the layer is being dragged", () => {
563
593
  expect(el.style.getPropertyValue("width")).toBe("100px");
564
594
  });
565
595
 
596
+ it("counts an undo shown or applied in place, so a reload requested before it loads again", () => {
597
+ const { iframe, doc } = buildLiveIframe(undone);
598
+ const before = studioManualEditSavesIn(doc);
599
+
600
+ showRestoreInPlace(iframe, ROOT, files, 3);
601
+ expect(studioManualEditSavesIn(doc)).toBeGreaterThan(before);
602
+ const shown = studioManualEditSavesIn(doc);
603
+ applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
604
+ expect(studioManualEditSavesIn(doc)).toBeGreaterThan(shown);
605
+ });
606
+
566
607
  it("keeps a drag that started after the undo was shown when the undo is put back", () => {
567
608
  const { iframe, doc } = buildLiveIframe(undone);
568
609
  const el = doc.getElementById("a")!;
@@ -14,7 +14,7 @@ import { findAuthoredElement, parseSavedSource } from "./authoredSource";
14
14
  import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
15
15
  import { markScenesStale } from "../player/sceneSwap";
16
16
  import { STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR } from "../components/editor/manualEditsTypes";
17
- import { studioGestureDraws } from "../components/editor/manualEditsDom";
17
+ import { countStudioPreviewChange, studioGestureDraws } from "../components/editor/manualEditsDom";
18
18
 
19
19
  type PreviewWindow = Window & {
20
20
  __player?: { seek?: (t: number) => void };
@@ -312,7 +312,8 @@ export function showRestoreInPlace(
312
312
  const doc = iframe?.contentDocument;
313
313
  const win = iframe?.contentWindow as PreviewWindow | null;
314
314
  const plan = doc && win ? planRestoreTargets(doc, activeCompPath ?? "index.html", files) : null;
315
- if (!iframe || !win || !plan || plan.scripted) return null;
315
+ if (!iframe || !doc || !win || !plan || plan.scripted) return null;
316
+ countStudioPreviewChange(doc);
316
317
  const before = plan.targets.map(
317
318
  ({ live, restored }) => [live, live.cloneNode(false) as Element, restored] as const,
318
319
  );
@@ -393,6 +394,7 @@ export function applyUndoRestoreToPreview(
393
394
  reloadPreview();
394
395
  return "full";
395
396
  }
397
+ countStudioPreviewChange(doc);
396
398
  // Sync each changed element's attributes onto the live DOM from the restored
397
399
  // markup, so the runtime's seek-reapply reads the reverted values.
398
400
  for (const target of plan.targets)
@@ -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(
@@ -6,6 +6,7 @@ import {
6
6
  beginStudioPendingEdit,
7
7
  flushStudioPendingEdits,
8
8
  hasStudioPendingEdits,
9
+ isStudioEditSaving,
9
10
  paintBackNewestStudioPendingEdit,
10
11
  trackStudioPendingEdit,
11
12
  trackedStudioEdit,
@@ -199,6 +200,128 @@ describe("a drain that meets a conflict", () => {
199
200
  });
200
201
  });
201
202
 
203
+ describe("a new edit after a debounced one", () => {
204
+ it("commits the debounced edit first, so history records them in the order they were made", () => {
205
+ const order: string[] = [];
206
+ const remove = addStudioPendingEditFlushListener(() => void order.push("nudge"));
207
+ trackedStudioEdit(() => void order.push("panel"))();
208
+ const drag = beginStudioPendingEdit(null);
209
+ drag.adopt(() => void order.push("drag"));
210
+ drag.settle();
211
+ remove();
212
+ expect(order).toEqual(["nudge", "panel", "nudge", "drag"]);
213
+ });
214
+ it("starts a new edit only after a flushed save that was still waiting has written", async () => {
215
+ const order: string[] = [];
216
+ let fetched!: () => void;
217
+ // A nudge on an animated layer: its save waits on the animation fetch before it writes.
218
+ const remove = addStudioPendingEditFlushListener(async () => {
219
+ await new Promise<void>((resolve) => (fetched = resolve));
220
+ order.push("nudge write");
221
+ });
222
+ const panelEdit = trackedStudioEdit(async () => void order.push("width write"), {
223
+ afterOlderSaves: true,
224
+ })();
225
+ fetched();
226
+ await panelEdit;
227
+ remove();
228
+ expect(order).toEqual(["nudge write", "width write"]);
229
+ });
230
+
231
+ it("keeps every later edit behind one that waits on a flushed save, so the newest value lands last", async () => {
232
+ const order: string[] = [];
233
+ let fetched!: () => void;
234
+ let saving = false;
235
+ const remove = addStudioPendingEditFlushListener(() => {
236
+ if (saving) return undefined;
237
+ saving = true;
238
+ return new Promise<void>((resolve) => (fetched = resolve)).then(
239
+ () => void order.push("nudge"),
240
+ );
241
+ });
242
+ const userEdit = (body: () => unknown) => trackedStudioEdit(body, { afterOlderSaves: true });
243
+ const first = userEdit(async () => void order.push("W 200"))();
244
+ const second = userEdit(async () => void order.push("W 300"))();
245
+ const drag = beginStudioPendingEdit(null);
246
+ const dragged = drag.adopt(() => Promise.resolve().then(() => void order.push("resize")));
247
+ fetched();
248
+ await Promise.all([first, second, dragged]);
249
+ drag.settle();
250
+ remove();
251
+ expect(order).toEqual(["nudge", "W 200", "W 300", "resize"]);
252
+ });
253
+
254
+ it("never waits on itself: a deferred edit or a flushed save may call a wrapped edit", async () => {
255
+ let fetched!: () => void;
256
+ let saving = false;
257
+ const internal = trackedStudioEdit(async () => undefined);
258
+ // The flushed save calls an internal wrapped commit after its fetch, as an animated nudge does.
259
+ const remove = addStudioPendingEditFlushListener(() => {
260
+ if (saving) return undefined;
261
+ saving = true;
262
+ return new Promise<void>((resolve) => (fetched = resolve)).then(() => internal());
263
+ });
264
+ const userEdit = (body: () => unknown) => trackedStudioEdit(body, { afterOlderSaves: true });
265
+ const panel = userEdit(() => internal())();
266
+ const drag = beginStudioPendingEdit(null);
267
+ const dragged = drag.adopt(() => internal());
268
+ fetched();
269
+ await Promise.all([panel, dragged]);
270
+ drag.settle();
271
+ await userEdit(async () => undefined)();
272
+ remove();
273
+ expect(isStudioEditSaving()).toBe(false);
274
+ });
275
+
276
+ it("lets a second burst flushed behind the first one save, though its commit is a panel action", async () => {
277
+ const order: string[] = [];
278
+ let fetched!: () => void;
279
+ const commitNudge = trackedStudioEdit(
280
+ async (name: string) => {
281
+ if (name === "nudge A") await new Promise<void>((resolve) => (fetched = resolve));
282
+ else await Promise.resolve();
283
+ order.push(name);
284
+ },
285
+ { afterOlderSaves: true },
286
+ );
287
+ let burst: { name: string; edit: ReturnType<typeof beginStudioPendingEdit> } | null = null;
288
+ const startBurst = (name: string) => {
289
+ const edit = beginStudioPendingEdit(null);
290
+ burst = { name, edit };
291
+ };
292
+ const remove = addStudioPendingEditFlushListener(() => {
293
+ if (!burst) return undefined;
294
+ const { name, edit } = burst;
295
+ burst = null;
296
+ const saved = edit.adopt(() => commitNudge(name));
297
+ edit.settle(saved);
298
+ return saved;
299
+ });
300
+ const panelEdit = trackedStudioEdit(async (name: string) => void order.push(name), {
301
+ afterOlderSaves: true,
302
+ });
303
+ startBurst("nudge A");
304
+ const first = panelEdit("W 200");
305
+ startBurst("nudge B");
306
+ const second = panelEdit("W 300");
307
+ fetched();
308
+ await Promise.all([first, second]);
309
+ remove();
310
+ expect(order).toEqual(["nudge A", "W 200", "nudge B", "W 300"]);
311
+ expect(isStudioEditSaving()).toBe(false);
312
+ });
313
+
314
+ it("never flushes from inside an edit's own save, where a flushed save would go untracked", () => {
315
+ let flushes = 0;
316
+ const remove = addStudioPendingEditFlushListener(() => void (flushes += 1));
317
+ const drag = beginStudioPendingEdit(null);
318
+ drag.adopt(() => trackedStudioEdit(() => undefined)());
319
+ drag.settle();
320
+ remove();
321
+ expect(flushes).toBe(1);
322
+ });
323
+ });
324
+
202
325
  describe("trackedStudioEdit", () => {
203
326
  it("counts each call as a pending edit until it settles, and reports a failure to the drain", async () => {
204
327
  let fail!: () => void;
@@ -66,8 +66,18 @@ export function hasStudioPendingEdits(): boolean {
66
66
  return pendingEdits.size > 0 || focusedField() !== null;
67
67
  }
68
68
 
69
+ const shownRestores = new Set<Promise<void>>();
70
+
71
+ export function beginStudioShownRestore(): () => void {
72
+ let end = () => {};
73
+ const landed = new Promise<void>((resolve) => (end = resolve));
74
+ shownRestores.add(landed);
75
+ void landed.then(() => shownRestores.delete(landed));
76
+ return end;
77
+ }
78
+
69
79
  export function isStudioEditSaving(): boolean {
70
- return pendingEdits.size > 0;
80
+ return pendingEdits.size > 0 || shownRestores.size > 0;
71
81
  }
72
82
 
73
83
  export function afterStudioPendingEdits(run: () => void): () => void {
@@ -75,7 +85,7 @@ export function afterStudioPendingEdits(run: () => void): () => void {
75
85
  const check = () => {
76
86
  if (!waiting) return;
77
87
  if (isStudioEditSaving()) {
78
- void Promise.allSettled([...pendingEdits.keys()]).then(check);
88
+ void Promise.allSettled([...pendingEdits.keys(), ...shownRestores]).then(check);
79
89
  return;
80
90
  }
81
91
  waiting = false;
@@ -107,10 +117,34 @@ export function trackStudioPendingEdit(
107
117
  return promise;
108
118
  }
109
119
 
120
+ let flushedSavesStillWriting: Promise<unknown> | null = null;
121
+
122
+ function commitOlderDebouncedEdits(): void {
123
+ if (typeof window === "undefined" || adopting) return;
124
+ const detail: StudioFlushPendingEditsDetail = { promises: [] };
125
+ window.dispatchEvent(
126
+ new CustomEvent<StudioFlushPendingEditsDetail>(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }),
127
+ );
128
+ if (!detail.promises.length) return;
129
+ const saves = Promise.allSettled([flushedSavesStillWriting, ...detail.promises]);
130
+ flushedSavesStillWriting = saves;
131
+ void saves.then(() => {
132
+ if (flushedSavesStillWriting === saves) flushedSavesStillWriting = null;
133
+ });
134
+ }
135
+
136
+ function afterOlderSaves<T>(run: () => T): Promise<T> | null {
137
+ return flushedSavesStillWriting && flushedSavesStillWriting.then(run);
138
+ }
139
+
110
140
  export function trackedStudioEdit<Args extends unknown[], R>(
111
141
  edit: (...args: Args) => R,
142
+ { afterOlderSaves: waits = false } = {},
112
143
  ): (...args: Args) => R {
113
144
  return (...args) => {
145
+ commitOlderDebouncedEdits();
146
+ const deferred = waits && !adopting ? afterOlderSaves(() => edit(...args)) : null;
147
+ if (deferred) return trackStudioPendingEdit(deferred) as R;
114
148
  const result = edit(...args);
115
149
  if (result instanceof Promise) trackStudioPendingEdit(result);
116
150
  return result;
@@ -118,6 +152,7 @@ export function trackedStudioEdit<Args extends unknown[], R>(
118
152
  }
119
153
 
120
154
  export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
155
+ commitOlderDebouncedEdits();
121
156
  let settle!: (saved?: Promise<unknown>) => void;
122
157
  const promise = trackStudioPendingEdit(new Promise<unknown>((resolve) => (settle = resolve)))!;
123
158
  const entry = pendingEdits.get(promise)!;
@@ -157,7 +192,8 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
157
192
  // Only what `start` registers synchronously is adopted; a later write joins only through `within`.
158
193
  adopt<T>(start: () => T): T {
159
194
  try {
160
- const committed = inFlight.within(start);
195
+ const deferred = afterOlderSaves(() => inFlight.within(start));
196
+ const committed = (deferred ?? inFlight.within(start)) as T;
161
197
  landed = Promise.resolve(committed).then(
162
198
  () => true,
163
199
  () => saved,