@hyperframes/studio 0.8.105 → 0.8.107
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-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
- package/dist/assets/index-CM1xA_jD.js +394 -0
- package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
- package/dist/assets/index-lrT-gfHa.css +1 -0
- package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
- package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
- package/dist/chunk-4OZC426H.js.map +1 -0
- package/dist/index.d.ts +381 -14
- package/dist/index.html +11 -2
- package/dist/index.js +9974 -9045
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +9 -9
- package/src/App.tsx +5 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +2 -1
- package/src/components/StudioHeader.dom.test.tsx +31 -9
- package/src/components/StudioHeader.tsx +4 -1
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/ThemeToggle.tsx +99 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/DomEditOverlay.tsx +31 -5
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- package/src/components/editor/SourceEditor.tsx +2 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +58 -2
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +4 -1
- package/src/components/editor/marqueeCommit.test.tsx +69 -1
- package/src/components/editor/marqueeCommit.ts +6 -1
- package/src/components/editor/mediaAudioEdits.test.ts +212 -0
- package/src/components/editor/mediaAudioEdits.ts +253 -0
- package/src/components/editor/overlayFrameLoop.test.ts +27 -0
- package/src/components/editor/overlayFrameLoop.ts +21 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.test.tsx +80 -3
- package/src/components/editor/propertyPanelFont.tsx +69 -46
- package/src/components/editor/propertyPanelHelpers.ts +1 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/studioCodeTheme.test.ts +20 -0
- package/src/components/editor/studioCodeTheme.ts +99 -0
- package/src/components/editor/useDomEditNudge.test.tsx +165 -2
- package/src/components/editor/useDomEditNudge.ts +43 -25
- package/src/components/editor/useInlineTextEditing.tsx +1 -1
- package/src/components/editor/useMotionPathData.test.tsx +64 -0
- package/src/components/editor/useMotionPathData.ts +5 -1
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/previewPromotionSelection.test.tsx +180 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +49 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +4 -5
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useClipboard.source.test.ts +99 -19
- package/src/hooks/useClipboard.ts +145 -124
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.test.tsx +76 -72
- package/src/hooks/useDomEditSession.ts +9 -46
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
- package/src/hooks/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useGsapTweenCache.ts +1 -2
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +21 -5
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/useLivePreviewIframe.ts +18 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
- package/src/hooks/usePreviewPersistence.ts +8 -5
- package/src/hooks/useRenderClipContent.ts +3 -3
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +9 -0
- package/src/livePreviewIframeExport.test.tsx +33 -0
- package/src/main.tsx +4 -1
- package/src/player/components/AudioWaveform.test.tsx +36 -15
- package/src/player/components/AudioWaveform.tsx +24 -7
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.test.tsx +38 -0
- package/src/player/components/TimelineClip.tsx +14 -6
- package/src/player/components/TimelineClipFades.test.tsx +376 -74
- package/src/player/components/TimelineClipFades.tsx +368 -137
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useTimelineKeyboardActor.ts +1 -1
- package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/hooks/useShadowPreviewReload.ts +72 -5
- package/src/player/lib/automationStoreSync.test.ts +18 -1
- package/src/player/lib/automationStoreSync.ts +29 -17
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +16 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- package/src/player/sceneSwap.ts +18 -0
- package/src/styles/components.css +26 -0
- package/src/styles/studio.css +6 -0
- package/src/styles/theme.css +10 -0
- package/src/utils/clipboardPayload.ts +10 -1
- package/src/utils/gsapSoftReload.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +63 -0
- package/src/utils/studioPendingEdits.ts +37 -7
- package/src/utils/studioTheme.test.tsx +141 -0
- package/src/utils/studioTheme.ts +34 -0
- package/src/utils/studioUiPreferences.ts +3 -0
- package/src/utils/typingTarget.test.ts +21 -1
- package/src/utils/typingTarget.ts +8 -0
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/assets/index-Cre9X4RS.css +0 -1
- package/dist/assets/index-DEDhPmPD.js +0 -399
- package/dist/chunk-QADHJFDF.js.map +0 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Real Chrome: the box GSAP and a stylesheet translate produce together is what no DOM emulation computes.
|
|
2
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import puppeteer, { type Browser } from "puppeteer-core";
|
|
8
|
+
import { build } from "vite";
|
|
9
|
+
import { afterAll, beforeAll, expect, it } from "vitest";
|
|
10
|
+
import { findSystemChrome } from "../../vite.browser";
|
|
11
|
+
import { writeFixture } from "../../tests/e2e/edit-accuracy/grid.mjs";
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const CROP = "inset(0px 40px 0px 0px)";
|
|
15
|
+
let browser: Browser;
|
|
16
|
+
let undoBundle: string;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
const executablePath = findSystemChrome();
|
|
20
|
+
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
|
|
21
|
+
browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
|
22
|
+
const out = await build({
|
|
23
|
+
configFile: false,
|
|
24
|
+
logLevel: "silent",
|
|
25
|
+
resolve: {
|
|
26
|
+
alias: { canvas: fileURLToPath(new URL("../shims/canvasBrowserStub.js", import.meta.url)) },
|
|
27
|
+
},
|
|
28
|
+
build: {
|
|
29
|
+
write: false,
|
|
30
|
+
minify: false,
|
|
31
|
+
lib: {
|
|
32
|
+
entry: fileURLToPath(new URL("./gsapUndoRestore.ts", import.meta.url)),
|
|
33
|
+
formats: ["iife"],
|
|
34
|
+
name: "hfUndo",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
undoBundle = (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0]
|
|
39
|
+
.code;
|
|
40
|
+
}, 60_000);
|
|
41
|
+
|
|
42
|
+
afterAll(() => browser?.close());
|
|
43
|
+
|
|
44
|
+
it.each([
|
|
45
|
+
{ gsap: "hold", placement: "px", rotation: 0 },
|
|
46
|
+
{ gsap: "tween", placement: "px", rotation: 0 },
|
|
47
|
+
{ gsap: "tween", placement: "xpercent", rotation: 30 },
|
|
48
|
+
])("an undone crop leaves a $gsap $placement r$rotation layer where it was", async (spec) => {
|
|
49
|
+
const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
|
|
50
|
+
writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
|
|
51
|
+
const restored = readFileSync(join(dir, "index.html"), "utf8");
|
|
52
|
+
const previous = restored.replace(
|
|
53
|
+
`<div id="target"`,
|
|
54
|
+
`<div id="target" style="clip-path: ${CROP}"`,
|
|
55
|
+
);
|
|
56
|
+
const page = await browser.newPage();
|
|
57
|
+
await page.setViewport({ width: 1920, height: 1080 });
|
|
58
|
+
await page.setRequestInterception(true);
|
|
59
|
+
page.on("request", (request) =>
|
|
60
|
+
request.url().endsWith("/gsap.min.js")
|
|
61
|
+
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
|
|
62
|
+
: request.continue(),
|
|
63
|
+
);
|
|
64
|
+
await page.setContent(restored, { waitUntil: "load" });
|
|
65
|
+
await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
|
|
66
|
+
await page.waitForFunction(() => "__player" in window);
|
|
67
|
+
await page.evaluate(undoBundle);
|
|
68
|
+
|
|
69
|
+
const box = () =>
|
|
70
|
+
page.evaluate(() => {
|
|
71
|
+
const { left, top, width, height } = document
|
|
72
|
+
.getElementById("target")!
|
|
73
|
+
.getBoundingClientRect();
|
|
74
|
+
return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
|
|
75
|
+
});
|
|
76
|
+
await page.evaluate(() =>
|
|
77
|
+
(window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
|
|
78
|
+
);
|
|
79
|
+
const before = await box();
|
|
80
|
+
const outcome = await page.evaluate(
|
|
81
|
+
(files, crop) => {
|
|
82
|
+
document.getElementById("target")!.style.setProperty("clip-path", crop);
|
|
83
|
+
const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
|
|
84
|
+
const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
|
|
85
|
+
return undo.applyUndoRestoreToPreview(iframe, "index.html", files, 1, () => {});
|
|
86
|
+
},
|
|
87
|
+
{ "index.html": { previous, restored } },
|
|
88
|
+
CROP,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(outcome).toBe("soft");
|
|
92
|
+
expect(await box()).toEqual(before);
|
|
93
|
+
await page.close();
|
|
94
|
+
});
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi } from "vitest";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
applyUndoRestoreToPreview,
|
|
6
|
+
diffSoftReloadableRestore,
|
|
7
|
+
showRestoreInPlace,
|
|
8
|
+
} from "./gsapUndoRestore";
|
|
9
|
+
import { applyPatch } from "./sourcePatcher";
|
|
10
|
+
import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
|
|
11
|
+
import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate";
|
|
5
12
|
|
|
6
13
|
// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
|
|
7
14
|
|
|
@@ -281,6 +288,25 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
281
288
|
);
|
|
282
289
|
});
|
|
283
290
|
|
|
291
|
+
it("full-reloads a style restore on a GSAP-parsed element when the file has two scripts", () => {
|
|
292
|
+
const scripts = [
|
|
293
|
+
`<script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
|
|
294
|
+
`<script>window.__timelines["captions"]=gsap.timeline().to("#a",{y:1});</script>`,
|
|
295
|
+
].join("");
|
|
296
|
+
const { iframe, doc } = buildLiveIframe(`<div id="a" style="z-index: 8">t</div>${scripts}`);
|
|
297
|
+
Object.assign(doc.getElementById("a")!, { _gsap: {} });
|
|
298
|
+
const reloadPreview = vi.fn();
|
|
299
|
+
const files = {
|
|
300
|
+
[ROOT]: {
|
|
301
|
+
previous: wrap(`<div id="a" style="z-index: 8">t</div>${scripts}`),
|
|
302
|
+
restored: wrap(`<div id="a" style="z-index: 3">t</div>${scripts}`),
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
307
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
308
|
+
});
|
|
309
|
+
|
|
284
310
|
it("full-reloads a multi-file restore", () => {
|
|
285
311
|
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
286
312
|
const reloadPreview = vi.fn();
|
|
@@ -420,3 +446,121 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
420
446
|
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
421
447
|
});
|
|
422
448
|
});
|
|
449
|
+
|
|
450
|
+
describe("an undo that lands while the layer is being dragged", () => {
|
|
451
|
+
const ROOT = "index.html";
|
|
452
|
+
const undone = `<div id="a" style="width: 120px; translate: 90px 60px" data-start="2" data-hf-studio-original-inline-translate="">t</div>`;
|
|
453
|
+
const restored = wrap(`<div id="a" style="width: 100px" data-start="1">t</div>`);
|
|
454
|
+
const files = { [ROOT]: { previous: wrap(undone), restored } };
|
|
455
|
+
const saveDrop = (el: HTMLElement, file: string) =>
|
|
456
|
+
writePlainMove(el, { x: 20, y: 110 }).reduce((html, op) => applyPatch(html, "a", op), file);
|
|
457
|
+
|
|
458
|
+
function dragging(drag: boolean) {
|
|
459
|
+
const { iframe, doc } = buildLiveIframe(undone);
|
|
460
|
+
const el = doc.getElementById("a")!;
|
|
461
|
+
if (drag) beginStudioManualEditGesture(el, "move");
|
|
462
|
+
if (drag) writeTranslatePx(el, { x: 70, y: 110 });
|
|
463
|
+
else el.style.setProperty("opacity", "0.5");
|
|
464
|
+
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
|
|
465
|
+
return el;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
it("keeps the translate the drag is drawing and reverts the rest", () => {
|
|
469
|
+
const el = dragging(true);
|
|
470
|
+
|
|
471
|
+
expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
|
|
472
|
+
expect(el.style.getPropertyValue("width")).toBe("100px");
|
|
473
|
+
expect(el.getAttribute("data-start")).toBe("1");
|
|
474
|
+
expect(el.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(true);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("saves the drop onto the undone file, the same bytes as dragging the undone layer", () => {
|
|
478
|
+
const el = dragging(true);
|
|
479
|
+
const fresh = new DOMParser().parseFromString(restored, "text/html").getElementById("a")!;
|
|
480
|
+
|
|
481
|
+
const saved = saveDrop(el, restored);
|
|
482
|
+
|
|
483
|
+
expect(saved).toBe(saveDrop(fresh, restored));
|
|
484
|
+
expect(saved).toContain("width: 100px; translate: 20px 110px");
|
|
485
|
+
expect(saved).not.toContain("120px");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("keeps the box where it is when the press has not moved it yet", () => {
|
|
489
|
+
const { iframe, doc } = buildLiveIframe(undone);
|
|
490
|
+
const el = doc.getElementById("a")!;
|
|
491
|
+
beginStudioManualEditGesture(el, "move");
|
|
492
|
+
|
|
493
|
+
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
|
|
494
|
+
|
|
495
|
+
expect(el.style.getPropertyValue("translate")).toBe("90px 60px");
|
|
496
|
+
expect(el.style.getPropertyValue("width")).toBe("100px");
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("reverts the translate under a gesture that draws nothing, such as a text edit", () => {
|
|
500
|
+
const { iframe, doc } = buildLiveIframe(undone);
|
|
501
|
+
const el = doc.getElementById("a")!;
|
|
502
|
+
beginStudioManualEditGesture(el, "edit");
|
|
503
|
+
|
|
504
|
+
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
|
|
505
|
+
|
|
506
|
+
expect(el.style.getPropertyValue("translate")).toBe("");
|
|
507
|
+
expect(el.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(true);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("keeps the drag's translate when the undo is shown in place", () => {
|
|
511
|
+
const { iframe, doc } = buildLiveIframe(undone);
|
|
512
|
+
const el = doc.getElementById("a")!;
|
|
513
|
+
beginStudioManualEditGesture(el, "move");
|
|
514
|
+
writeTranslatePx(el, { x: 70, y: 110 });
|
|
515
|
+
|
|
516
|
+
expect(showRestoreInPlace(iframe, ROOT, files, 3)).not.toBeNull();
|
|
517
|
+
|
|
518
|
+
expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
|
|
519
|
+
expect(el.style.getPropertyValue("width")).toBe("100px");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("keeps a drag that started after the undo was shown when the undo is put back", () => {
|
|
523
|
+
const { iframe, doc } = buildLiveIframe(undone);
|
|
524
|
+
const el = doc.getElementById("a")!;
|
|
525
|
+
const putBack = showRestoreInPlace(iframe, ROOT, files, 3)!;
|
|
526
|
+
beginStudioManualEditGesture(el, "move");
|
|
527
|
+
writeTranslatePx(el, { x: 70, y: 110 });
|
|
528
|
+
|
|
529
|
+
putBack(3);
|
|
530
|
+
|
|
531
|
+
expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
|
|
532
|
+
expect(el.style.getPropertyValue("width")).toBe("120px");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("keeps the drag's translate when the undo re-runs a GSAP script", () => {
|
|
536
|
+
const script = (extra: string) =>
|
|
537
|
+
`<script>window.__timelines["root"]=gsap.timeline();${extra}</script>`;
|
|
538
|
+
const layer = `<div id="a" data-hf-studio-path-offset="true" style="translate: 40px 30px">t</div>`;
|
|
539
|
+
const { iframe, contentWindow, doc } = buildLiveIframe(
|
|
540
|
+
`${layer}${script("tl.set('#b',{x:1});")}`,
|
|
541
|
+
);
|
|
542
|
+
Object.assign(contentWindow.gsap, { set: () => {} });
|
|
543
|
+
const el = doc.getElementById("a")!;
|
|
544
|
+
beginStudioManualEditGesture(el, "move");
|
|
545
|
+
writeTranslatePx(el, { x: 70, y: 110 });
|
|
546
|
+
const scripted = {
|
|
547
|
+
[ROOT]: {
|
|
548
|
+
previous: wrap(`<div id="a">t</div>${script("tl.set('#b',{x:1});")}`),
|
|
549
|
+
restored: wrap(`<div id="a">t</div>${script("")}`),
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, scripted, 3, vi.fn())).toBe("soft");
|
|
554
|
+
|
|
555
|
+
expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("restores a layer no gesture is drawing exactly as before", () => {
|
|
559
|
+
const el = dragging(false);
|
|
560
|
+
const want = new DOMParser().parseFromString(restored, "text/html").getElementById("a")!;
|
|
561
|
+
|
|
562
|
+
expect(el.getAttributeNames().map((n) => [n, el.getAttribute(n)])).toEqual(
|
|
563
|
+
want.getAttributeNames().map((n) => [n, want.getAttribute(n)]),
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
@@ -12,6 +12,8 @@ import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
|
|
|
12
12
|
import { findAuthoredElement, parseSavedSource } from "./authoredSource";
|
|
13
13
|
import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
|
|
14
14
|
import { markScenesStale } from "../player/sceneSwap";
|
|
15
|
+
import { STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR } from "../components/editor/manualEditsTypes";
|
|
16
|
+
import { studioGestureDraws } from "../components/editor/manualEditsDom";
|
|
15
17
|
|
|
16
18
|
type PreviewWindow = Window & {
|
|
17
19
|
__player?: { seek?: (t: number) => void };
|
|
@@ -140,23 +142,55 @@ function diffRestoreDocs(prevDoc: Document, nextDoc: Document): string[] | null
|
|
|
140
142
|
return prevRest === nextRest ? changedElementKeys : null;
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
/**
|
|
144
|
-
function syncElementAttributes(target: Element, source: Element): void {
|
|
145
|
+
/** Copies `source`'s attributes onto `target`; under a gesture mark it merges them (keepDrawn). */
|
|
146
|
+
function syncElementAttributes(target: Element, source: Element, base?: Element): void {
|
|
147
|
+
const draws = base && studioGestureDraws(target);
|
|
148
|
+
const merged = draws ? keepDrawn(target, source, base, draws) : source;
|
|
145
149
|
for (const name of [...target.getAttributeNames()]) {
|
|
146
|
-
if (!
|
|
150
|
+
if (!merged.hasAttribute(name)) target.removeAttribute(name);
|
|
147
151
|
}
|
|
148
|
-
for (const name of
|
|
149
|
-
target.setAttribute(name,
|
|
152
|
+
for (const name of merged.getAttributeNames()) {
|
|
153
|
+
target.setAttribute(name, merged.getAttribute(name) ?? "");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** `source`, plus what a gesture in progress draws on `live` (whatever its value) or changed since `base`. */
|
|
158
|
+
function keepDrawn(live: Element, source: Element, base: Element, draws: readonly string[]) {
|
|
159
|
+
const merged = source.cloneNode(false) as Element;
|
|
160
|
+
const keepsMark = draws.includes("translate");
|
|
161
|
+
for (const name of live.getAttributeNames().filter((n) => n !== "style")) {
|
|
162
|
+
const value = live.getAttribute(name)!;
|
|
163
|
+
const mark = keepsMark && name === STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR;
|
|
164
|
+
if (mark || value !== base.getAttribute(name)) merged.setAttribute(name, value);
|
|
165
|
+
}
|
|
166
|
+
keepDrawnStyle(live as HTMLElement, base as HTMLElement, merged as HTMLElement, draws);
|
|
167
|
+
return merged;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function keepDrawnStyle(
|
|
171
|
+
live: HTMLElement,
|
|
172
|
+
base: HTMLElement,
|
|
173
|
+
merged: HTMLElement,
|
|
174
|
+
draws: readonly string[],
|
|
175
|
+
) {
|
|
176
|
+
const value = (prop: string) => live.style.getPropertyValue(prop);
|
|
177
|
+
const drawn = (prop: string) =>
|
|
178
|
+
draws.includes(prop) || value(prop) !== base.style.getPropertyValue(prop);
|
|
179
|
+
const props = new Set([...Array.from(live.style), ...Array.from(base.style), ...draws]);
|
|
180
|
+
for (const prop of [...props].filter(drawn)) {
|
|
181
|
+
if (value(prop))
|
|
182
|
+
merged.style.setProperty(prop, value(prop), live.style.getPropertyPriority(prop));
|
|
183
|
+
else merged.style.removeProperty(prop);
|
|
150
184
|
}
|
|
151
185
|
}
|
|
152
186
|
|
|
153
187
|
// A gesture folded into the script leaves marks on the live element that every seek would re-impose.
|
|
154
|
-
function syncStaleEditMarks(doc: Document,
|
|
155
|
-
const restoredDoc = parseSavedSource
|
|
188
|
+
function syncStaleEditMarks(doc: Document, file: UndoRestoreFile): void {
|
|
189
|
+
const [restoredDoc, previousDoc] = [file.restored, file.previous].map(parseSavedSource);
|
|
156
190
|
for (const live of doc.querySelectorAll(STUDIO_EDIT_ATTRS.map((attr) => `[${attr}]`).join())) {
|
|
157
191
|
const source = findAuthoredElement(restoredDoc, live);
|
|
158
192
|
if (source && STUDIO_EDIT_ATTRS.some((a) => live.getAttribute(a) !== source.getAttribute(a))) {
|
|
159
|
-
syncElementAttributes(live, source);
|
|
193
|
+
syncElementAttributes(live, source, findAuthoredElement(previousDoc, live) ?? undefined);
|
|
160
194
|
}
|
|
161
195
|
}
|
|
162
196
|
}
|
|
@@ -172,7 +206,7 @@ function hasAmbiguousGsapScriptChange(
|
|
|
172
206
|
);
|
|
173
207
|
}
|
|
174
208
|
|
|
175
|
-
type RestoreTarget = { live: Element; restored: Element };
|
|
209
|
+
type RestoreTarget = { live: Element; restored: Element; previous?: Element };
|
|
176
210
|
type RestorePlan = { targets: RestoreTarget[]; scripted: boolean };
|
|
177
211
|
|
|
178
212
|
/** Whether either side has a GSAP script, or null when its scripts rule out an in-place restore. */
|
|
@@ -196,6 +230,7 @@ function scopeTargets(
|
|
|
196
230
|
scope: ParentNode,
|
|
197
231
|
keys: string[],
|
|
198
232
|
restoredByKey: Map<string, Element>,
|
|
233
|
+
previousByKey: Map<string, Element>,
|
|
199
234
|
): RestoreTarget[] | null {
|
|
200
235
|
const liveByKey = identityElementMap(scope);
|
|
201
236
|
if (!liveByKey) return null;
|
|
@@ -205,7 +240,7 @@ function scopeTargets(
|
|
|
205
240
|
const restored = restoredByKey.get(key);
|
|
206
241
|
// The preview rewrites a sub-composition's own root, so its attributes are not the file's.
|
|
207
242
|
if (!live || !restored || live.hasAttribute("data-hf-inner-root")) return null;
|
|
208
|
-
targets.push({ live, restored });
|
|
243
|
+
targets.push({ live, restored, previous: previousByKey.get(key) });
|
|
209
244
|
}
|
|
210
245
|
return targets;
|
|
211
246
|
}
|
|
@@ -222,9 +257,10 @@ function fileTargets(
|
|
|
222
257
|
if (scripted === null) return null;
|
|
223
258
|
const keys = diffRestoreDocs(prevDoc, nextDoc);
|
|
224
259
|
const restoredByKey = identityElementMap(nextDoc);
|
|
225
|
-
|
|
260
|
+
const previousByKey = identityElementMap(prevDoc);
|
|
261
|
+
if (!keys || !restoredByKey || !previousByKey) return null;
|
|
226
262
|
const found = liveScopes(doc, path, isActive).map((scope) =>
|
|
227
|
-
scopeTargets(scope, keys, restoredByKey),
|
|
263
|
+
scopeTargets(scope, keys, restoredByKey, previousByKey),
|
|
228
264
|
);
|
|
229
265
|
if (!found.length || found.includes(null)) return null;
|
|
230
266
|
return { targets: (found as RestoreTarget[][]).flat(), scripted };
|
|
@@ -273,11 +309,14 @@ export function showRestoreInPlace(
|
|
|
273
309
|
const win = iframe?.contentWindow as PreviewWindow | null;
|
|
274
310
|
const plan = doc && win ? planRestoreTargets(doc, activeCompPath ?? "index.html", files) : null;
|
|
275
311
|
if (!iframe || !win || !plan || plan.scripted) return null;
|
|
276
|
-
const before = plan.targets.map(
|
|
277
|
-
|
|
312
|
+
const before = plan.targets.map(
|
|
313
|
+
({ live, restored }) => [live, live.cloneNode(false) as Element, restored] as const,
|
|
314
|
+
);
|
|
315
|
+
for (const { live, restored, previous } of plan.targets)
|
|
316
|
+
syncElementAttributes(live, restored, previous);
|
|
278
317
|
const putBack = (time: number) => {
|
|
279
318
|
if (iframe.contentDocument !== doc) return false;
|
|
280
|
-
for (const [live, attributes] of before) syncElementAttributes(live, attributes);
|
|
319
|
+
for (const [live, attributes, shown] of before) syncElementAttributes(live, attributes, shown);
|
|
281
320
|
return finalizeInPlace(iframe, win, time);
|
|
282
321
|
};
|
|
283
322
|
if (finalizeInPlace(iframe, win, currentTime)) return putBack;
|
|
@@ -299,9 +338,10 @@ export function showRestoreInPlace(
|
|
|
299
338
|
* element — so a canvas-position revert lands on the live DOM the runtime's
|
|
300
339
|
* seek-reapply reads from, not just on disk.
|
|
301
340
|
* 2. The runtime refresh depends on what changed:
|
|
302
|
-
* - GSAP script text actually CHANGED between previous and restored
|
|
303
|
-
* restored script is re-run in
|
|
304
|
-
* `currentTime`, re-folds manual
|
|
341
|
+
* - GSAP script text actually CHANGED between previous and restored, or a
|
|
342
|
+
* synced element was parsed by GSAP → the restored script is re-run in
|
|
343
|
+
* place via applySoftReload (re-seeks to `currentTime`, re-folds manual
|
|
344
|
+
* edits, re-parses those elements); several scripts can't, so it reloads.
|
|
305
345
|
* - Script unchanged or absent (the overwhelmingly common undo: z-order,
|
|
306
346
|
* lane move, timing shift, style tweak) → NO script execution — the
|
|
307
347
|
* blink-free finalization only (seek + __hfForceTimelineRebind + manual
|
|
@@ -338,17 +378,26 @@ export function applyUndoRestoreToPreview(
|
|
|
338
378
|
}
|
|
339
379
|
// Sync each changed element's attributes onto the live DOM from the restored
|
|
340
380
|
// markup, so the runtime's seek-reapply reads the reverted values.
|
|
341
|
-
for (const target of plan.targets)
|
|
381
|
+
for (const target of plan.targets)
|
|
382
|
+
syncElementAttributes(target.live, target.restored, target.previous);
|
|
342
383
|
|
|
343
384
|
const active = files[activeDocPath];
|
|
344
385
|
const restoredScript = active ? extractGsapScriptText(active.restored) : null;
|
|
345
386
|
const previousScript = active ? extractGsapScriptText(active.previous) : null;
|
|
346
|
-
|
|
347
|
-
|
|
387
|
+
const reparse = plan.scripted
|
|
388
|
+
? plan.targets.map(({ live }) => live).filter((el) => "_gsap" in el)
|
|
389
|
+
: [];
|
|
390
|
+
if (reparse.length && !restoredScript) {
|
|
391
|
+
reloadPreview();
|
|
392
|
+
return "full";
|
|
393
|
+
}
|
|
394
|
+
if (restoredScript && (restoredScript !== previousScript || reparse.length)) {
|
|
395
|
+
syncStaleEditMarks(doc, active);
|
|
348
396
|
const result = applySoftReload(iframe, restoredScript, {
|
|
349
397
|
onAsyncFailure: reloadPreview,
|
|
350
398
|
currentTimeOverride: currentTime,
|
|
351
399
|
authoredHtml: active.restored,
|
|
400
|
+
reparse,
|
|
352
401
|
});
|
|
353
402
|
if (result === "cannot-soft-reload") {
|
|
354
403
|
reloadPreview();
|
|
@@ -12,7 +12,7 @@ function escapeStyleAttributeValue(value: string, quote: string): string {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/** Escape a string for safe use inside a double-quoted HTML attribute. */
|
|
15
|
-
function escapeHtmlAttribute(value: string): string {
|
|
15
|
+
export function escapeHtmlAttribute(value: string): string {
|
|
16
16
|
return value
|
|
17
17
|
.replace(/&/g, "&")
|
|
18
18
|
.replace(/"/g, """)
|
|
@@ -60,27 +60,6 @@ export function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsse
|
|
|
60
60
|
doc.head.appendChild(style);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
export function
|
|
64
|
-
|
|
65
|
-
asset: ImportedFontAsset,
|
|
66
|
-
sourceFile: string,
|
|
67
|
-
): string {
|
|
68
|
-
const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
|
|
69
|
-
if (html.includes(css)) return html;
|
|
70
|
-
|
|
71
|
-
const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
|
|
72
|
-
const styleMatch = styleRe.exec(html);
|
|
73
|
-
if (styleMatch) {
|
|
74
|
-
const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
|
|
75
|
-
return html.replace(
|
|
76
|
-
styleMatch[0],
|
|
77
|
-
() => `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
|
|
82
|
-
if (/<\/head>/i.test(html)) {
|
|
83
|
-
return html.replace(/<\/head>/i, () => ` ${styleTag}\n </head>`);
|
|
84
|
-
}
|
|
85
|
-
return `${styleTag}\n${html}`;
|
|
63
|
+
export function importedFontFaceCssFor(asset: ImportedFontAsset, sourceFile: string): string {
|
|
64
|
+
return importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
|
|
86
65
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
historyTooltipLabel,
|
|
6
6
|
findMatchingTimelineElementId,
|
|
7
7
|
findTimelineIdByAncestor,
|
|
8
|
+
resolveAssetHasAudio,
|
|
8
9
|
resolveDroppedAssetDimensions,
|
|
9
10
|
resolveElementTrack,
|
|
10
11
|
resolveTimelineIdForSelection,
|
|
@@ -235,3 +236,32 @@ describe("historyTooltipLabel", () => {
|
|
|
235
236
|
expect(historyTooltipLabel("redo", null)).toMatch(/^Redo \(.+\)$/);
|
|
236
237
|
});
|
|
237
238
|
});
|
|
239
|
+
|
|
240
|
+
describe("resolveAssetHasAudio", () => {
|
|
241
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
242
|
+
|
|
243
|
+
const stubMetadata = (response: Response | Error) =>
|
|
244
|
+
vi.stubGlobal(
|
|
245
|
+
"fetch",
|
|
246
|
+
vi.fn(async () => {
|
|
247
|
+
if (response instanceof Error) throw response;
|
|
248
|
+
return response;
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
it("answers the metadata endpoint's audio flag", async () => {
|
|
253
|
+
stubMetadata(new Response(JSON.stringify({ metadata: { hasAudio: true } })));
|
|
254
|
+
expect(await resolveAssetHasAudio("p1", "assets/talk.mp4")).toBe(true);
|
|
255
|
+
stubMetadata(new Response(JSON.stringify({ metadata: { hasAudio: false } })));
|
|
256
|
+
expect(await resolveAssetHasAudio("p1", "assets/cutout.webm")).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("answers null when the probe cannot tell", async () => {
|
|
260
|
+
stubMetadata(new Response("nope", { status: 500 }));
|
|
261
|
+
expect(await resolveAssetHasAudio("p1", "assets/talk.mp4")).toBeNull();
|
|
262
|
+
stubMetadata(new Error("offline"));
|
|
263
|
+
expect(await resolveAssetHasAudio("p1", "assets/talk.mp4")).toBeNull();
|
|
264
|
+
stubMetadata(new Response(JSON.stringify({ metadata: {} })));
|
|
265
|
+
expect(await resolveAssetHasAudio("p1", "assets/talk.mp4")).toBeNull();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -388,13 +388,27 @@ export async function resolveDroppedAssetHasAudio(
|
|
|
388
388
|
kind: TimelineAssetKind,
|
|
389
389
|
): Promise<boolean> {
|
|
390
390
|
if (kind !== "video") return false;
|
|
391
|
+
return (await resolveAssetHasAudio(projectId, assetPath)) === true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
395
|
+
return typeof value === "object" && value !== null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** An asset's audio stream from the metadata endpoint: null when the probe can't tell. */
|
|
399
|
+
export async function resolveAssetHasAudio(
|
|
400
|
+
projectId: string,
|
|
401
|
+
assetPath: string,
|
|
402
|
+
): Promise<boolean | null> {
|
|
391
403
|
try {
|
|
392
404
|
const response = await fetch(mediaMetadataUrl(projectId, assetPath));
|
|
393
|
-
if (!response.ok) return
|
|
394
|
-
const data =
|
|
395
|
-
|
|
405
|
+
if (!response.ok) return null;
|
|
406
|
+
const data: unknown = await response.json();
|
|
407
|
+
const metadata = isPlainRecord(data) ? data.metadata : undefined;
|
|
408
|
+
const hasAudio = isPlainRecord(metadata) ? metadata.hasAudio : undefined;
|
|
409
|
+
return typeof hasAudio === "boolean" ? hasAudio : null;
|
|
396
410
|
} catch {
|
|
397
|
-
return
|
|
411
|
+
return null;
|
|
398
412
|
}
|
|
399
413
|
}
|
|
400
414
|
|
|
@@ -3,7 +3,9 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
3
3
|
import {
|
|
4
4
|
addStudioPendingEditFlushListener,
|
|
5
5
|
flushStudioPendingEdits,
|
|
6
|
+
hasStudioPendingEdits,
|
|
6
7
|
trackStudioPendingEdit,
|
|
8
|
+
trackedStudioEdit,
|
|
7
9
|
} from "./studioPendingEdits";
|
|
8
10
|
import { StudioFileConflictError } from "./studioSaveDiagnostics";
|
|
9
11
|
|
|
@@ -36,6 +38,22 @@ describe("studio pending edit flush", () => {
|
|
|
36
38
|
input.remove();
|
|
37
39
|
});
|
|
38
40
|
|
|
41
|
+
it.each([
|
|
42
|
+
["contenteditable", "plaintext-only"],
|
|
43
|
+
["role", "combobox"],
|
|
44
|
+
["role", "searchbox"],
|
|
45
|
+
["role", "switch"],
|
|
46
|
+
])("counts a focused [%s=%s] as a pending edit", (attribute, value) => {
|
|
47
|
+
const field = document.createElement("div");
|
|
48
|
+
field.setAttribute(attribute, value);
|
|
49
|
+
field.tabIndex = 0;
|
|
50
|
+
document.body.append(field);
|
|
51
|
+
field.focus();
|
|
52
|
+
|
|
53
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
54
|
+
field.remove();
|
|
55
|
+
});
|
|
56
|
+
|
|
39
57
|
it("waits for a post-blur effect to register its pending edit listener", async () => {
|
|
40
58
|
const input = document.createElement("textarea");
|
|
41
59
|
document.body.append(input);
|
|
@@ -148,3 +166,48 @@ describe("studio pending edit flush", () => {
|
|
|
148
166
|
expect(steps).toEqual(["persisted", "flushed"]);
|
|
149
167
|
});
|
|
150
168
|
});
|
|
169
|
+
|
|
170
|
+
describe("a drain that meets a conflict", () => {
|
|
171
|
+
it("still waits for the edits that started while it ran before it reports the conflict", async () => {
|
|
172
|
+
const conflict = new StudioFileConflictError({
|
|
173
|
+
filePath: "index.html",
|
|
174
|
+
currentVersion: "v2",
|
|
175
|
+
currentContent: "external",
|
|
176
|
+
attemptedContent: "studio",
|
|
177
|
+
});
|
|
178
|
+
let finish!: () => void;
|
|
179
|
+
let laterSaved = false;
|
|
180
|
+
trackStudioPendingEdit(
|
|
181
|
+
Promise.resolve().then(() => {
|
|
182
|
+
trackStudioPendingEdit(
|
|
183
|
+
new Promise<void>((resolve) => (finish = resolve)).then(() => (laterSaved = true)),
|
|
184
|
+
);
|
|
185
|
+
throw conflict;
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
let drained = false;
|
|
189
|
+
const drain = flushStudioPendingEdits().then((result) => ((drained = true), result));
|
|
190
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
191
|
+
expect(drained).toBe(false);
|
|
192
|
+
finish();
|
|
193
|
+
await expect(drain).resolves.toEqual({ status: "conflict", error: conflict });
|
|
194
|
+
expect(laterSaved).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("trackedStudioEdit", () => {
|
|
199
|
+
it("counts each call as a pending edit until it settles, and reports a failure to the drain", async () => {
|
|
200
|
+
let fail!: () => void;
|
|
201
|
+
const failure = new Error("The save failed.");
|
|
202
|
+
const edit = trackedStudioEdit(
|
|
203
|
+
() => new Promise<void>((_, reject) => (fail = () => reject(failure))),
|
|
204
|
+
);
|
|
205
|
+
const saved = edit();
|
|
206
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
207
|
+
const drain = flushStudioPendingEdits();
|
|
208
|
+
fail();
|
|
209
|
+
await expect(saved).rejects.toThrow("The save failed.");
|
|
210
|
+
await expect(drain).resolves.toEqual({ status: "failed", error: failure });
|
|
211
|
+
expect(hasStudioPendingEdits()).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
});
|