@hyperframes/studio 0.8.133 → 0.8.134
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-Bro1gj0Z.js} +1 -1
- package/dist/assets/{index-ME-Z5jh6.js → index-BwNDTmAD.js} +1 -1
- package/dist/assets/{index-CwgWmfgx.js → index-DnVGIbEC.js} +1 -1
- package/dist/assets/{index-Dj21zXWA.js → index-wnXyQjW-.js} +141 -141
- package/dist/index.html +1 -1
- package/dist/index.js +684 -603
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- 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/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/gsapResizeIntercept.test.ts +72 -1
- package/src/hooks/gsapResizeIntercept.ts +84 -17
- package/src/hooks/gsapRuntimeBridge.ts +4 -1
- package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
- 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/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +10 -2
- package/src/utils/elementGsap.ts +6 -5
- package/src/utils/gsapUndoRestore.test.ts +22 -0
- package/src/utils/gsapUndoRestore.ts +4 -2
- package/src/utils/studioPendingEdits.ts +12 -2
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { trimTrailingKeyframeSpans } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
2
|
+
import { gsap } from "gsap";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
|
|
6
|
+
|
|
7
|
+
// What Studio saved for a plain element keyed at 1, 2 and 3 s, and for its size keyed at 3 s then 2 s.
|
|
8
|
+
const saved = {
|
|
9
|
+
position:
|
|
10
|
+
`tl.set("#target", { x: 299.893, data: "hf-hold" }, 0);\n` +
|
|
11
|
+
`tl.to("#target", { keyframes: { "0%": { x: 299.893 }, "33.333%": { x: 297.086 }, "66.667%": { x: 47.754 } }, duration: 3 }, 1);`,
|
|
12
|
+
size:
|
|
13
|
+
`tl.set("#target", { width: 440, data: "hf-hold" }, 0);\n` +
|
|
14
|
+
`tl.to("#target", { keyframes: { "0%": { width: 440 }, "50%": { width: 340, ease: "none" } }, duration: 2 }, 2);`,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function valueAt(script: string, prop: "x" | "width", seeks: number[]) {
|
|
18
|
+
const target: Record<string, number> = { x: 0, width: 240 };
|
|
19
|
+
const run = new Function(
|
|
20
|
+
"gsap",
|
|
21
|
+
"target",
|
|
22
|
+
`${script.replaceAll('"#target"', "target")}\nreturn tl;`,
|
|
23
|
+
);
|
|
24
|
+
const tl = run(gsap, target) as gsap.core.Timeline;
|
|
25
|
+
for (const time of seeks) tl.seek(time);
|
|
26
|
+
return target[prop];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe.each([
|
|
30
|
+
["position", "x", 47.754],
|
|
31
|
+
["size", "width", 340],
|
|
32
|
+
] as const)("a %s tween Studio keyed short of its end", (name, prop, atThree) => {
|
|
33
|
+
it("plays and seeks to the same value once its tail is trimmed", () => {
|
|
34
|
+
const script = trimTrailingKeyframeSpans(timeline, timeline + saved[name]);
|
|
35
|
+
expect(valueAt(script, prop, [3])).toBeCloseTo(atThree, 3);
|
|
36
|
+
expect(valueAt(script, prop, [1, 2, 3])).toBeCloseTo(atThree, 3);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("is what GSAP 3.15 gets wrong on a direct seek without the trim", () => {
|
|
40
|
+
const script = timeline + saved[name];
|
|
41
|
+
expect(valueAt(script, prop, [1, 2, 3])).toBeCloseTo(atThree, 3);
|
|
42
|
+
expect(valueAt(script, prop, [3])).not.toBeCloseTo(atThree, 0);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -7,6 +7,7 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
|
7
7
|
import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
|
|
8
8
|
import {
|
|
9
9
|
beginStudioPendingEdit,
|
|
10
|
+
isStudioEditSaving,
|
|
10
11
|
setStudioPendingEditClaimClock,
|
|
11
12
|
trackStudioPendingEdit,
|
|
12
13
|
} from "../utils/studioPendingEdits";
|
|
@@ -97,6 +98,25 @@ describe("useEditHistoryActions", () => {
|
|
|
97
98
|
});
|
|
98
99
|
});
|
|
99
100
|
|
|
101
|
+
it("counts a shown step as saving until the server takes it, so a preview reload waits for its write", async () => {
|
|
102
|
+
const { deps, actions } = mount(
|
|
103
|
+
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
|
|
104
|
+
PREDICTED,
|
|
105
|
+
);
|
|
106
|
+
let step!: () => void;
|
|
107
|
+
const stepped = new Promise<void>((resolve) => (step = resolve));
|
|
108
|
+
const undo = deps.editHistory.undo.getMockImplementation()!;
|
|
109
|
+
deps.editHistory.undo.mockImplementation(async (cb) => (await stepped, undo(cb)));
|
|
110
|
+
|
|
111
|
+
const undone = actions.undo();
|
|
112
|
+
expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files);
|
|
113
|
+
expect(isStudioEditSaving()).toBe(true);
|
|
114
|
+
step();
|
|
115
|
+
await act(() => undone);
|
|
116
|
+
await Promise.resolve();
|
|
117
|
+
expect(isStudioEditSaving()).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
100
120
|
it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => {
|
|
101
121
|
const { deps, actions } = mount(
|
|
102
122
|
{
|
|
@@ -4,6 +4,7 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
|
4
4
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
5
5
|
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
6
6
|
import {
|
|
7
|
+
beginStudioShownRestore,
|
|
7
8
|
hasStudioPendingEdits,
|
|
8
9
|
paintBackNewestStudioPendingEdit,
|
|
9
10
|
} from "../utils/studioPendingEdits";
|
|
@@ -82,6 +83,7 @@ export function useEditHistoryActions({
|
|
|
82
83
|
const pendingEditShown = direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
|
|
83
84
|
const predicted = pendingEditShown ? null : (editHistory.predict?.(direction) ?? null);
|
|
84
85
|
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
|
|
86
|
+
const shownLanded = predictedShown ? beginStudioShownRestore() : null;
|
|
85
87
|
const putBack = predictedShown ?? pendingEditShown?.showAgain;
|
|
86
88
|
const claimedAfter =
|
|
87
89
|
pendingEditShown?.claimsAtBegin ??
|
|
@@ -107,6 +109,7 @@ export function useEditHistoryActions({
|
|
|
107
109
|
: stepped && editClaimed;
|
|
108
110
|
} finally {
|
|
109
111
|
if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
|
|
112
|
+
shownLanded?.();
|
|
110
113
|
}
|
|
111
114
|
if (!result.ok && result.reason === "content-mismatch") {
|
|
112
115
|
showToast(
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { POSITION_PROPS } from "./gsapRuntimeReaders";
|
|
32
32
|
import { findParsedTween, withLiveTiming } from "./gsapParsedTween";
|
|
33
33
|
import { roundTo3 } from "../utils/rounding";
|
|
34
|
+
import { readTranslatePxLeavingPercent } from "../components/editor/plainTranslate";
|
|
34
35
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
35
36
|
import {
|
|
36
37
|
buildExtendedKeyframes,
|
|
@@ -180,6 +181,32 @@ export function resolveNewTweenRange(
|
|
|
180
181
|
return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) };
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
export function singleKeyTweenMutation(
|
|
185
|
+
sel: DomEditSelection,
|
|
186
|
+
properties: Record<string, number>,
|
|
187
|
+
currentTime: number,
|
|
188
|
+
): Record<string, unknown> | null {
|
|
189
|
+
// A brand-new tween: author it against the one element the user selected.
|
|
190
|
+
// The bare class selectorFromSelection hands back for an id-less element
|
|
191
|
+
// animates every sibling sharing the class (see writeTargetSelector).
|
|
192
|
+
const targetSelector = writeTargetSelector(sel);
|
|
193
|
+
if (!targetSelector) return null;
|
|
194
|
+
const { start, duration } = resolveNewTweenRange(
|
|
195
|
+
sel.dataAttributes?.start,
|
|
196
|
+
sel.dataAttributes?.duration,
|
|
197
|
+
currentTime,
|
|
198
|
+
);
|
|
199
|
+
// One keyframe at the playhead, a single diamond capturing the current value;
|
|
200
|
+
// motion comes from the keyframes the user adds or drags later.
|
|
201
|
+
return {
|
|
202
|
+
type: "add-with-keyframes",
|
|
203
|
+
targetSelector,
|
|
204
|
+
position: roundTo3(start),
|
|
205
|
+
duration: roundTo3(duration),
|
|
206
|
+
keyframes: [{ percentage: 0, properties }],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
183
210
|
// Authoritative parse of the current source for `sel`. Returns `null` when the
|
|
184
211
|
// fetch can't run (no projectId / request failed) so callers can distinguish
|
|
185
212
|
// "unavailable" from a genuine empty result (e.g. after a delete-all). An empty
|
|
@@ -514,43 +541,11 @@ export function useEnableKeyframes(
|
|
|
514
541
|
}
|
|
515
542
|
} else {
|
|
516
543
|
const position = readElementPosition(iframe, sel, null);
|
|
517
|
-
|
|
518
|
-
sel.
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
// A brand-new tween: author it against the one element the user selected.
|
|
523
|
-
// The bare class selectorFromSelection hands back for an id-less element
|
|
524
|
-
// animates every sibling sharing the class (see writeTargetSelector).
|
|
525
|
-
const selector = writeTargetSelector(sel);
|
|
526
|
-
|
|
527
|
-
if (!selector) {
|
|
528
|
-
session.handleGsapAddAnimation("to");
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
if (Object.keys(position).length === 0) {
|
|
533
|
-
position.x = 0;
|
|
534
|
-
position.y = 0;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// One keyframe at the playhead — a single diamond capturing the current
|
|
538
|
-
// value. Motion comes from the user adding/dragging more keyframes later;
|
|
539
|
-
// creating 0%+100% up front showed two diamonds for a single "add keyframe".
|
|
540
|
-
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
|
541
|
-
[{ percentage: 0, properties: { ...position } }];
|
|
542
|
-
|
|
543
|
-
if (session.commitMutation) {
|
|
544
|
-
await session.commitMutation(
|
|
545
|
-
{
|
|
546
|
-
type: "add-with-keyframes",
|
|
547
|
-
targetSelector: selector,
|
|
548
|
-
position: roundTo3(elStart),
|
|
549
|
-
duration: roundTo3(elDuration),
|
|
550
|
-
keyframes,
|
|
551
|
-
},
|
|
552
|
-
{ label: "Enable keyframes", softReload: true },
|
|
553
|
-
);
|
|
544
|
+
if (Object.keys(position).length === 0)
|
|
545
|
+
Object.assign(position, readTranslatePxLeavingPercent(sel.element));
|
|
546
|
+
const mutation = singleKeyTweenMutation(sel, position, t);
|
|
547
|
+
if (mutation && session.commitMutation) {
|
|
548
|
+
await session.commitMutation(mutation, { label: "Enable keyframes", softReload: true });
|
|
554
549
|
} else {
|
|
555
550
|
session.handleGsapAddAnimation("to");
|
|
556
551
|
}
|
|
@@ -16,7 +16,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
16
16
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
17
17
|
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
|
|
18
18
|
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
19
|
-
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
19
|
+
import { computeDraggedGsapPosition, freezeDragStamp } from "./draggedGsapPosition";
|
|
20
20
|
import { readGsapPositionFromIframe } from "./gsapPositionDetection";
|
|
21
21
|
import { selectorFromSelection } from "./gsapShared";
|
|
22
22
|
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
|
|
@@ -209,6 +209,7 @@ export function useGsapAwareEditing({
|
|
|
209
209
|
restore: () => void = () => undefined,
|
|
210
210
|
route?: { plainTranslate: boolean },
|
|
211
211
|
) => {
|
|
212
|
+
const stamp = freezeDragStamp(selection.element);
|
|
212
213
|
const writes = observeGsapGesture(gsapCommitMutation);
|
|
213
214
|
if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) {
|
|
214
215
|
const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
|
|
@@ -256,6 +257,7 @@ export function useGsapAwareEditing({
|
|
|
256
257
|
previewIframeRef.current,
|
|
257
258
|
commitMutation,
|
|
258
259
|
makeFetchFallback(selection),
|
|
260
|
+
{ stamp },
|
|
259
261
|
);
|
|
260
262
|
// Saved after the size, under its undo key, so the two are one step.
|
|
261
263
|
await saveMove(dragOutcome, async () => {
|
|
@@ -279,7 +281,12 @@ export function useGsapAwareEditing({
|
|
|
279
281
|
x: 0,
|
|
280
282
|
y: 0,
|
|
281
283
|
};
|
|
282
|
-
const { newX, newY } = computeDraggedGsapPosition(
|
|
284
|
+
const { newX, newY } = computeDraggedGsapPosition(
|
|
285
|
+
selection.element,
|
|
286
|
+
offset,
|
|
287
|
+
gsapPos,
|
|
288
|
+
stamp,
|
|
289
|
+
);
|
|
283
290
|
logResize("sync-settle", { gsapPos, offset, newX, newY });
|
|
284
291
|
setElementGsapPosition(selection.element, newX, newY);
|
|
285
292
|
});
|
|
@@ -297,6 +304,7 @@ export function useGsapAwareEditing({
|
|
|
297
304
|
makeFetchFallback(selection),
|
|
298
305
|
offset,
|
|
299
306
|
writes.drawKeepingUndone,
|
|
307
|
+
stamp,
|
|
300
308
|
);
|
|
301
309
|
assertGsapEditPersisted(outcome);
|
|
302
310
|
// Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
|
package/src/utils/elementGsap.ts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
type ElementGsapWindow = Window & {
|
|
9
9
|
gsap?: {
|
|
10
10
|
set?: (target: Element, vars: Record<string, number>) => void;
|
|
11
|
-
getProperty?: (target: Element, prop: string) => unknown;
|
|
12
11
|
};
|
|
13
12
|
};
|
|
14
13
|
|
|
@@ -38,10 +37,12 @@ export function setElementGsapScale(element: HTMLElement, x: number, y: number):
|
|
|
38
37
|
return true;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
/**
|
|
42
|
-
export function
|
|
43
|
-
const
|
|
44
|
-
|
|
40
|
+
/** Set the element's GSAP width/height. Returns false when no runtime is reachable. */
|
|
41
|
+
export function setElementGsapSize(element: HTMLElement, width: number, height: number): boolean {
|
|
42
|
+
const gsap = gsapOf(element);
|
|
43
|
+
if (!gsap?.set) return false;
|
|
44
|
+
gsap.set(element, { width, height });
|
|
45
|
+
return true;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/** The targets CSSPlugin styles: not plain objects (the runtime's filler) or XML-namespace elements. */
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "./gsapUndoRestore";
|
|
22
22
|
import { applyPatch } from "./sourcePatcher";
|
|
23
23
|
import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
|
|
24
|
+
import { studioManualEditSavesIn } from "../components/editor/manualEditsDom";
|
|
24
25
|
import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate";
|
|
25
26
|
|
|
26
27
|
// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
|
|
@@ -281,6 +282,16 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
281
282
|
expect(doc.getElementById("a")!.getAttribute("style")).toBe("z-index: 8");
|
|
282
283
|
});
|
|
283
284
|
|
|
285
|
+
it("counts a script-only restore applied in place, so a reload requested before it loads again", () => {
|
|
286
|
+
const script = (x: number) => `window.__timelines["root"]=gsap.timeline().to("#a",{x:${x}});`;
|
|
287
|
+
const page = (x: number) => `<div id="a">t</div><script>${script(x)}</script>`;
|
|
288
|
+
const { iframe, doc } = buildLiveIframe(page(2));
|
|
289
|
+
const before = studioManualEditSavesIn(doc);
|
|
290
|
+
const files = { [ROOT]: { previous: wrap(page(2)), restored: wrap(page(1)) } };
|
|
291
|
+
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
|
|
292
|
+
expect(studioManualEditSavesIn(doc)).toBeGreaterThan(before);
|
|
293
|
+
});
|
|
294
|
+
|
|
284
295
|
it("does NOT re-run an UNCHANGED GSAP script for an attribute-only restore", () => {
|
|
285
296
|
// The live doc holds a script element; a re-run would mutate/remove it
|
|
286
297
|
// (applySoftReload removes stale script elements before re-running).
|
|
@@ -563,6 +574,17 @@ describe("an undo that lands while the layer is being dragged", () => {
|
|
|
563
574
|
expect(el.style.getPropertyValue("width")).toBe("100px");
|
|
564
575
|
});
|
|
565
576
|
|
|
577
|
+
it("counts an undo shown or applied in place, so a reload requested before it loads again", () => {
|
|
578
|
+
const { iframe, doc } = buildLiveIframe(undone);
|
|
579
|
+
const before = studioManualEditSavesIn(doc);
|
|
580
|
+
|
|
581
|
+
showRestoreInPlace(iframe, ROOT, files, 3);
|
|
582
|
+
expect(studioManualEditSavesIn(doc)).toBeGreaterThan(before);
|
|
583
|
+
const shown = studioManualEditSavesIn(doc);
|
|
584
|
+
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
|
|
585
|
+
expect(studioManualEditSavesIn(doc)).toBeGreaterThan(shown);
|
|
586
|
+
});
|
|
587
|
+
|
|
566
588
|
it("keeps a drag that started after the undo was shown when the undo is put back", () => {
|
|
567
589
|
const { iframe, doc } = buildLiveIframe(undone);
|
|
568
590
|
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)
|
|
@@ -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;
|