@hyperframes/studio 0.8.133 → 0.8.135
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-Cjcwveg0.js → hyperframes-player-DFv9heE6.js} +1 -1
- package/dist/assets/{index-CwgWmfgx.js → index-Buy06mZy.js} +1 -1
- package/dist/assets/{index-ME-Z5jh6.js → index-CTp4loy9.js} +1 -1
- package/dist/assets/{index-Dj21zXWA.js → index-N_4Ij4X6.js} +184 -183
- package/dist/assets/{index-D11atkBt.css → index-syMK0_9C.css} +1 -1
- package/dist/{chunk-5VSXRGIX.js → chunk-NM7BXNZ7.js} +1 -1
- package/dist/chunk-NM7BXNZ7.js.map +1 -0
- package/dist/{chunk-FOEID72M.js → chunk-QRLKWENQ.js} +2 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.html +2 -2
- package/dist/index.js +911 -691
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +3 -2
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/editor/AnimationCard.test.tsx +60 -0
- package/src/components/editor/AnimationCard.tsx +16 -6
- package/src/components/editor/KeyframeEaseList.tsx +4 -1
- package/src/components/editor/LayersPanel.tsx +39 -47
- package/src/components/editor/domEditInlineText.ts +1 -1
- package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
- package/src/components/editor/gsapAnimationHelpers.ts +34 -0
- package/src/components/editor/layerKind.test.ts +38 -0
- package/src/components/editor/layerKind.ts +19 -0
- package/src/components/editor/manualEditsDom.ts +5 -1
- package/src/components/editor/plainTranslate.test.ts +21 -2
- package/src/components/editor/plainTranslate.ts +7 -0
- package/src/components/editor/useDomEditNudge.test.tsx +48 -27
- package/src/components/ui/cn.ts +2 -1
- package/src/contexts/DomEditContext.tsx +3 -1
- package/src/hooks/draggedGsapPosition.test.ts +34 -0
- package/src/hooks/draggedGsapPosition.ts +40 -15
- package/src/hooks/gsapDragCommit.ts +17 -10
- package/src/hooks/gsapDragPositionCommit.ts +8 -9
- package/src/hooks/gsapPlaybackTestHarness.ts +28 -0
- package/src/hooks/gsapResizeIntercept.test.ts +72 -1
- package/src/hooks/gsapResizeIntercept.ts +84 -17
- package/src/hooks/gsapRuntimeBridge.ts +4 -1
- package/src/hooks/gsapRuntimePatch.ts +4 -0
- package/src/hooks/gsapShared.test.ts +11 -0
- package/src/hooks/gsapShared.ts +1 -1
- package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
- package/src/hooks/keyframeEase.realGsap.test.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +33 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +3 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +21 -0
- package/src/hooks/useDomGeometryCommits.ts +6 -1
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +3 -0
- package/src/hooks/useEnableKeyframes.ts +32 -37
- package/src/hooks/useGestureCommit.test.tsx +89 -0
- package/src/hooks/useGestureCommit.ts +26 -9
- package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +10 -2
- package/src/icons/SystemIcons.tsx +6 -0
- package/src/layersPanelExport.test.tsx +45 -4
- package/src/utils/elementGsap.ts +6 -5
- package/src/utils/gsapKeyframeEases.ts +2 -0
- package/src/utils/gsapLiveRetime.cache.test.ts +39 -0
- package/src/utils/gsapLiveRetime.realGsap.test.ts +39 -15
- package/src/utils/gsapLiveRetime.ts +39 -31
- package/src/utils/gsapSoftReload.test.ts +98 -14
- package/src/utils/gsapSoftReload.ts +14 -2
- package/src/utils/gsapUndoRestore.test.ts +43 -2
- package/src/utils/gsapUndoRestore.ts +4 -2
- package/src/utils/softReloadTargets.ts +56 -0
- package/src/utils/studioPendingEdits.test.ts +123 -0
- package/src/utils/studioPendingEdits.ts +39 -3
- package/dist/chunk-5VSXRGIX.js.map +0 -1
- /package/dist/{chunk-FOEID72M.js.map → chunk-QRLKWENQ.js.map} +0 -0
|
@@ -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,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 = (
|
|
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
|
|
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,
|