@hyperframes/studio 0.8.116 → 0.8.117
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-mGYvPVpw.js → hyperframes-player-5fcgSstJ.js} +1 -1
- package/dist/assets/{index-DYNaXPVf.js → index-C5qKdpgl.js} +178 -176
- package/dist/assets/{index-BWHt0-ir.js → index-XJSawwYe.js} +1 -1
- package/dist/assets/{index-Dr3hNpvS.js → index-tg2LXUeu.js} +1 -1
- package/dist/index.d.ts +28 -19
- package/dist/index.html +1 -1
- package/dist/index.js +77 -64
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditOverlay.tsx +1 -0
- package/src/components/editor/domEditOverlayGestures.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
- package/src/components/editor/manualOffsetDrag.ts +3 -3
- package/src/components/editor/plainTranslate.ts +5 -3
- package/src/components/editor/propertyPanelHelpers.ts +2 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
- package/src/components/editor/rotationDraft.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
- package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
- package/src/hooks/gsapScriptCommitTypes.ts +1 -1
- package/src/hooks/plainRotation.ts +5 -1
- package/src/hooks/useDomEditPersist.test.tsx +335 -0
- package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
- package/src/hooks/useDomGeometryCommit.ts +6 -9
- package/src/hooks/useFreezeFrame.test.ts +39 -3
- package/src/hooks/useFreezeFrame.ts +4 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +25 -0
- package/src/hooks/useGsapAwareEditing.ts +12 -8
- package/src/hooks/useGsapAwareGroupMove.ts +2 -2
- package/src/hooks/useGsapScriptCommits.test.tsx +69 -1
- package/src/hooks/useGsapScriptCommits.ts +9 -7
- package/src/hooks/usePersistentEditHistory.test.ts +25 -2
- package/src/hooks/usePersistentEditHistory.ts +3 -9
- package/src/utils/gsapSoftReload.test.ts +39 -0
- package/src/utils/gsapSoftReload.ts +7 -11
- package/src/utils/gsapUndoRestore.browser.test.ts +91 -44
- package/src/utils/softReloadTargets.ts +4 -3
- package/src/utils/studioFileHistory.ts +1 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
1
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
5
|
import type { TimelineElement } from "../player";
|
|
3
|
-
import { requestFreezeFrame } from "./useFreezeFrame";
|
|
6
|
+
import { requestFreezeFrame, useFreezeFrame } from "./useFreezeFrame";
|
|
4
7
|
|
|
5
8
|
afterEach(() => vi.unstubAllGlobals());
|
|
6
9
|
|
|
@@ -29,14 +32,15 @@ function stubFetch(freezeResponse: { status: number; body: unknown }) {
|
|
|
29
32
|
|
|
30
33
|
describe("requestFreezeFrame", () => {
|
|
31
34
|
it("posts the clip target, file version and an authored-time playhead", async () => {
|
|
32
|
-
const
|
|
35
|
+
const frozen = { before: "a", after: "b", version: "v2", stillPath: "assets/freeze/t.png" };
|
|
36
|
+
const calls = stubFetch({ status: 200, body: frozen });
|
|
33
37
|
const result = await requestFreezeFrame({
|
|
34
38
|
projectId: "p",
|
|
35
39
|
path: "scene.html",
|
|
36
40
|
element,
|
|
37
41
|
playhead: 7.5,
|
|
38
42
|
});
|
|
39
|
-
expect(result).toEqual(
|
|
43
|
+
expect(result).toEqual(frozen);
|
|
40
44
|
const post = calls[1];
|
|
41
45
|
expect(post?.url).toContain("/file-mutations/freeze-frame");
|
|
42
46
|
expect(post?.body).toMatchObject({
|
|
@@ -54,3 +58,35 @@ describe("requestFreezeFrame", () => {
|
|
|
54
58
|
).rejects.toThrow("Move the playhead inside a video clip to freeze");
|
|
55
59
|
});
|
|
56
60
|
});
|
|
61
|
+
|
|
62
|
+
describe("useFreezeFrame", () => {
|
|
63
|
+
it("records the still it made with the edit, so Undo removes it", async () => {
|
|
64
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
65
|
+
stubFetch({
|
|
66
|
+
status: 200,
|
|
67
|
+
body: { before: "a", after: "b", version: "v2", stillPath: "assets/freeze/talk.png" },
|
|
68
|
+
});
|
|
69
|
+
const recordEdit = vi.fn(async () => {});
|
|
70
|
+
let freeze!: ReturnType<typeof useFreezeFrame>;
|
|
71
|
+
function Harness() {
|
|
72
|
+
freeze = useFreezeFrame({
|
|
73
|
+
projectId: "p",
|
|
74
|
+
activeCompPath: "index.html",
|
|
75
|
+
showToast: () => {},
|
|
76
|
+
writeProjectFile: async () => {},
|
|
77
|
+
recordEdit,
|
|
78
|
+
reloadPreview: () => {},
|
|
79
|
+
});
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const root = createRoot(document.createElement("div"));
|
|
83
|
+
await act(async () => root.render(createElement(Harness)));
|
|
84
|
+
await act(() => freeze({ ...element, sourceFile: "index.html" }, 7.5));
|
|
85
|
+
expect(recordEdit).toHaveBeenCalledWith({
|
|
86
|
+
label: "Freeze frame",
|
|
87
|
+
files: { "index.html": { before: "a", after: "b" } },
|
|
88
|
+
created: ["assets/freeze/talk.png"],
|
|
89
|
+
});
|
|
90
|
+
act(() => root.unmount());
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -14,6 +14,7 @@ interface FreezeFrameResponse {
|
|
|
14
14
|
before: string;
|
|
15
15
|
after: string;
|
|
16
16
|
version: string;
|
|
17
|
+
stillPath: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
|
|
@@ -22,7 +23,8 @@ function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
|
|
|
22
23
|
return (
|
|
23
24
|
typeof body.before === "string" &&
|
|
24
25
|
typeof body.after === "string" &&
|
|
25
|
-
typeof body.version === "string"
|
|
26
|
+
typeof body.version === "string" &&
|
|
27
|
+
typeof body.stillPath === "string"
|
|
26
28
|
);
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -109,6 +111,7 @@ export function useFreezeFrame(options: UseFreezeFrameOptions) {
|
|
|
109
111
|
await opts.recordEdit({
|
|
110
112
|
label: "Freeze frame",
|
|
111
113
|
files: { [path]: { before: result.before, after: result.after } },
|
|
114
|
+
created: [result.stillPath],
|
|
112
115
|
});
|
|
113
116
|
opts.observeProjectFileVersion?.(path, result.version);
|
|
114
117
|
});
|
|
@@ -74,6 +74,7 @@ function mountResizeHandler(
|
|
|
74
74
|
size: { width: number; height: number },
|
|
75
75
|
offset?: { x: number; y: number },
|
|
76
76
|
restore?: () => void,
|
|
77
|
+
route?: { plainTranslate: boolean },
|
|
77
78
|
) => Promise<void | import("../utils/previewFeatureUsage").GeometryCommitResult>)
|
|
78
79
|
| null = null;
|
|
79
80
|
let property: ReturnType<typeof useGsapAwareEditing>["commitAnimatedProperty"] | null = null;
|
|
@@ -234,6 +235,21 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
|
|
|
234
235
|
});
|
|
235
236
|
});
|
|
236
237
|
|
|
238
|
+
describe("useGsapAwareEditing keeps the box route a resize chose at press", () => {
|
|
239
|
+
it.each([
|
|
240
|
+
["the CSS route, on an element GSAP has since taken over", true],
|
|
241
|
+
["the GSAP route, on an element that now looks GSAP-free", false],
|
|
242
|
+
])("%s", async (_, plainTranslate) => {
|
|
243
|
+
mocks.resize.mockResolvedValue({ status: "persisted" });
|
|
244
|
+
const h = mountResizeHandler([], [], plainTranslate);
|
|
245
|
+
const size = { width: 300, height: 200 };
|
|
246
|
+
await act(() => h.resize(h.selection, size, undefined, vi.fn(), { plainTranslate }));
|
|
247
|
+
expect(h.fallback).toHaveBeenCalledTimes(plainTranslate ? 1 : 0);
|
|
248
|
+
expect(mocks.resize).toHaveBeenCalledTimes(plainTranslate ? 0 : 1);
|
|
249
|
+
act(() => h.root.unmount());
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
237
253
|
describe("useGsapAwareEditing refuses a group GSAP took over before writing any member", () => {
|
|
238
254
|
it("writes no member when one CSS-route member has been folded since the press", async () => {
|
|
239
255
|
const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
|
|
@@ -296,6 +312,15 @@ describe("useGsapAwareEditing rotation routing", () => {
|
|
|
296
312
|
act(() => h.root.unmount());
|
|
297
313
|
});
|
|
298
314
|
|
|
315
|
+
it("saves a turn the press sent down the GSAP route there, even once the element looks GSAP-free", async () => {
|
|
316
|
+
vi.mocked(tryGsapRotationIntercept).mockResolvedValue({ status: "persisted" });
|
|
317
|
+
const h = rotate(document.createElement("div"));
|
|
318
|
+
await act(() => h.rotationCommit(h.box, { angle: 55, plain: null }));
|
|
319
|
+
expect(tryGsapRotationIntercept).toHaveBeenCalledTimes(1);
|
|
320
|
+
expect(h.handleDomRotationCommit).not.toHaveBeenCalled();
|
|
321
|
+
act(() => h.root.unmount());
|
|
322
|
+
});
|
|
323
|
+
|
|
299
324
|
it("saves a turn the press drew as CSS by the CSS writer, even once a GSAP tween turns the element", async () => {
|
|
300
325
|
const element = document.createElement("div");
|
|
301
326
|
const h = rotate(element);
|
|
@@ -30,9 +30,7 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
|
|
|
30
30
|
import type { MoveCommitOptions } from "../components/editor/domEditOverlayGestures";
|
|
31
31
|
import { runGestureTransaction } from "./gestureTransaction";
|
|
32
32
|
import {
|
|
33
|
-
|
|
34
|
-
gsapWritesPosition,
|
|
35
|
-
gsapWritesRotation,
|
|
33
|
+
editsPlainCss,
|
|
36
34
|
hasNonHoldTweenForElement,
|
|
37
35
|
POSITION_CHANNELS,
|
|
38
36
|
} from "./gsapRuntimeKeyframes";
|
|
@@ -151,7 +149,7 @@ export function useGsapAwareEditing({
|
|
|
151
149
|
modifiers?: MoveCommitOptions,
|
|
152
150
|
) => {
|
|
153
151
|
const writes = observeGsapGesture(gsapCommitMutation);
|
|
154
|
-
if (modifiers?.plainTranslate ??
|
|
152
|
+
if (modifiers?.plainTranslate ?? editsPlainCss(selection.element, "move")) {
|
|
155
153
|
const result = await stageElementPositionOffset(selection, next, true).save();
|
|
156
154
|
return writes.finish(result?.changed === true);
|
|
157
155
|
}
|
|
@@ -207,9 +205,10 @@ export function useGsapAwareEditing({
|
|
|
207
205
|
next: { width: number; height: number },
|
|
208
206
|
offset?: { x: number; y: number },
|
|
209
207
|
restore: () => void = () => undefined,
|
|
208
|
+
route?: { plainTranslate: boolean },
|
|
210
209
|
) => {
|
|
211
210
|
const writes = observeGsapGesture(gsapCommitMutation);
|
|
212
|
-
if (
|
|
211
|
+
if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) {
|
|
213
212
|
const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
|
|
214
213
|
return writes.finish(result?.changed === true);
|
|
215
214
|
}
|
|
@@ -258,8 +257,13 @@ export function useGsapAwareEditing({
|
|
|
258
257
|
);
|
|
259
258
|
// Saved after the size, under its undo key, so the two are one step.
|
|
260
259
|
await saveMove(dragOutcome, async () => {
|
|
261
|
-
const
|
|
262
|
-
anchorMove = stageElementPositionOffset(
|
|
260
|
+
const plainAfterSettle = editsPlainCss(selection.element, "move");
|
|
261
|
+
anchorMove = stageElementPositionOffset(
|
|
262
|
+
selection,
|
|
263
|
+
offset,
|
|
264
|
+
plainAfterSettle,
|
|
265
|
+
coalesceKey,
|
|
266
|
+
);
|
|
263
267
|
});
|
|
264
268
|
}
|
|
265
269
|
};
|
|
@@ -360,7 +364,7 @@ export function useGsapAwareEditing({
|
|
|
360
364
|
const handleGsapAwareRotationCommit = useCallback(
|
|
361
365
|
async (selection: DomEditSelection, next: RotationCommit) => {
|
|
362
366
|
const writes = observeGsapGesture(gsapCommitMutation);
|
|
363
|
-
if (next.plain
|
|
367
|
+
if (next.plain === undefined ? editsPlainCss(selection.element, "rotate") : next.plain) {
|
|
364
368
|
const result = await handleDomRotationCommit(selection, next);
|
|
365
369
|
return writes.finish(result?.changed === true);
|
|
366
370
|
}
|
|
@@ -5,7 +5,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
5
5
|
import type { CommitMutationCall, CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
6
6
|
import type { UseGsapAwareEditingParams } from "./useGsapAwareEditing";
|
|
7
7
|
import { observeGsapGesture } from "./gsapGestureOutcome";
|
|
8
|
-
import {
|
|
8
|
+
import { editsPlainCss } from "./gsapRuntimeKeyframes";
|
|
9
9
|
import { refuseGsapTakeover } from "./elementOffsetStager";
|
|
10
10
|
import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
|
|
11
11
|
import { assertGsapEditPersisted } from "./gsapEditOutcome";
|
|
@@ -81,7 +81,7 @@ export function useGsapAwareGroupMove({
|
|
|
81
81
|
// write nothing and share one in-flight parse per file, so they run together.
|
|
82
82
|
const preflightResults = await Promise.allSettled(
|
|
83
83
|
updates.map(async ({ selection, next, plainTranslate }) => {
|
|
84
|
-
if (plainTranslate ??
|
|
84
|
+
if (plainTranslate ?? editsPlainCss(selection.element, "move")) {
|
|
85
85
|
refuseGsapTakeover(selection.element, toastRefusal ? showToast : () => {});
|
|
86
86
|
return void offsetMembers.set(selection, true);
|
|
87
87
|
}
|
|
@@ -30,7 +30,11 @@ vi.mock("../utils/studioTelemetry", () => ({
|
|
|
30
30
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
31
31
|
|
|
32
32
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
33
|
-
import type {
|
|
33
|
+
import type {
|
|
34
|
+
CommitMutationCall,
|
|
35
|
+
CommitMutationOptions,
|
|
36
|
+
MutationResult,
|
|
37
|
+
} from "./gsapScriptCommitTypes";
|
|
34
38
|
import { persistSdkSerialize } from "../utils/sdkCutover";
|
|
35
39
|
import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
36
40
|
import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
|
|
@@ -211,6 +215,34 @@ describe("applyPreviewSync", () => {
|
|
|
211
215
|
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
|
212
216
|
});
|
|
213
217
|
|
|
218
|
+
it("carries a deferred write with no instant patch into the final batch render", () => {
|
|
219
|
+
const previewFallbackLatch = { pending: false };
|
|
220
|
+
applySoftReload.mockReturnValue("applied");
|
|
221
|
+
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
222
|
+
const group = {
|
|
223
|
+
label: "Move animated layer (group)",
|
|
224
|
+
softReload: true,
|
|
225
|
+
previewFallbackLatch,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
applyPreviewSync(
|
|
229
|
+
FAKE_IFRAME,
|
|
230
|
+
result({ scriptText: "SCRIPT" }),
|
|
231
|
+
{ ...group, deferPreviewSync: true },
|
|
232
|
+
vi.fn(),
|
|
233
|
+
);
|
|
234
|
+
expect(previewFallbackLatch.pending).toBe(true);
|
|
235
|
+
expect(applySoftReload).not.toHaveBeenCalled();
|
|
236
|
+
|
|
237
|
+
applyPreviewSync(
|
|
238
|
+
FAKE_IFRAME,
|
|
239
|
+
result({ scriptText: "SCRIPT" }),
|
|
240
|
+
{ ...group, instantPatch: { selector: "#final", change: { kind: "set", props: { x: 2 } } } },
|
|
241
|
+
vi.fn(),
|
|
242
|
+
);
|
|
243
|
+
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
|
244
|
+
});
|
|
245
|
+
|
|
214
246
|
it("falls back immediately when a deferred patch miss has no final-render latch", () => {
|
|
215
247
|
patchRuntimeTweenInPlace.mockReturnValue(false);
|
|
216
248
|
applySoftReload.mockReturnValue("applied");
|
|
@@ -416,6 +448,16 @@ function renderCommitHook(
|
|
|
416
448
|
|
|
417
449
|
const selection: DomEditSelection = { id: "a", selector: "#a" } as DomEditSelection;
|
|
418
450
|
|
|
451
|
+
async function commitBatch(calls: CommitMutationCall[], options: CommitMutationOptions) {
|
|
452
|
+
const deps = renderCommitHook();
|
|
453
|
+
const batch = deps.api.commitMutation.batch;
|
|
454
|
+
if (!batch) throw new Error("batch capability missing");
|
|
455
|
+
await act(async () => {
|
|
456
|
+
await batch(calls, options);
|
|
457
|
+
});
|
|
458
|
+
return deps;
|
|
459
|
+
}
|
|
460
|
+
|
|
419
461
|
function mockFetchResult(over: Partial<MutationResult> = {}): void {
|
|
420
462
|
const body: MutationResult = {
|
|
421
463
|
ok: true,
|
|
@@ -553,6 +595,32 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
553
595
|
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
554
596
|
});
|
|
555
597
|
|
|
598
|
+
it("batch where a write without an instant patch soft-reloads instead of patching the rest", async () => {
|
|
599
|
+
// A corner resize saves size (no in-place patch) and position (patched) in one batch.
|
|
600
|
+
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
601
|
+
applySoftReload.mockReturnValue("applied");
|
|
602
|
+
mockFetchResult({ changed: true });
|
|
603
|
+
const position = { selector: "#a", change: { kind: "set" as const, props: { x: 10 } } };
|
|
604
|
+
const deps = await commitBatch(
|
|
605
|
+
[
|
|
606
|
+
{
|
|
607
|
+
selection,
|
|
608
|
+
mutation: { type: "update-property", property: "width", value: 300 },
|
|
609
|
+
options: { label: "Resize layer" },
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
selection,
|
|
613
|
+
mutation: { type: "update-property", property: "x", value: 10 },
|
|
614
|
+
options: { label: "Resize layer", instantPatch: position },
|
|
615
|
+
},
|
|
616
|
+
],
|
|
617
|
+
{ label: "Resize layer", softReload: true },
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
|
621
|
+
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
622
|
+
});
|
|
623
|
+
|
|
556
624
|
it("no-op commit whose instant patch MISSES soft-reloads (never full-reloads)", async () => {
|
|
557
625
|
// Server contract: gsap-mutations returns scriptText on EVERY response,
|
|
558
626
|
// including changed:false — so the fallback re-runs the identical script
|
|
@@ -268,7 +268,8 @@ export function applyPreviewSync(
|
|
|
268
268
|
nestedFiles?: Map<string, string> | null,
|
|
269
269
|
): void {
|
|
270
270
|
const patches = instantPatchesFor(options);
|
|
271
|
-
|
|
271
|
+
const writtenWithoutPatch = patches.length === 0;
|
|
272
|
+
let needsFallback = options.previewFallbackLatch?.pending === true || writtenWithoutPatch;
|
|
272
273
|
if (patches.length > 0) {
|
|
273
274
|
const deferSeek = options.deferPreviewSync === true;
|
|
274
275
|
const missed = patches.find(
|
|
@@ -410,13 +411,14 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
410
411
|
if (!result) return;
|
|
411
412
|
trackKeyframeCommit(mutations, result, options, calls.map((call) => call.options));
|
|
412
413
|
options.onResult?.(result);
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
const instantPatches = calls
|
|
416
|
-
.
|
|
417
|
-
|
|
414
|
+
// The batch wrote every call, so patch in place only when every call brought a patch; one
|
|
415
|
+
// without (a resize's size write) needs the reload, or the preview keeps the old value.
|
|
416
|
+
const instantPatches = calls.flatMap(({ options: callOptions }) =>
|
|
417
|
+
callOptions.instantPatch ? [callOptions.instantPatch] : [],
|
|
418
|
+
);
|
|
418
419
|
const { instantPatch: _instantPatch, ...batchOptions } = options;
|
|
419
|
-
|
|
420
|
+
const allPatched = instantPatches.length === calls.length;
|
|
421
|
+
await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, allPatched ? { ...batchOptions, instantPatches } : batchOptions);
|
|
420
422
|
}, [showToast, finalizeSuccessfulMutation]);
|
|
421
423
|
|
|
422
424
|
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// fallow-ignore-file code-duplication
|
|
3
3
|
import { act, createElement } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
8
|
import { afterEach, expect, it, vi } from "vitest";
|
|
9
9
|
import {
|
|
10
10
|
createStudioApi,
|
|
@@ -316,6 +316,29 @@ it("refuses a second press whose edit is undone once an edit was made after the
|
|
|
316
316
|
expect(file()).toBe("B");
|
|
317
317
|
});
|
|
318
318
|
|
|
319
|
+
it("a file the edit made (a freeze's still) is deleted by its Undo and put back by its Redo", async () => {
|
|
320
|
+
const { dir, hook, save, readFile } = await studio();
|
|
321
|
+
const still = join(dir, "assets", "freeze", "talk.png");
|
|
322
|
+
mkdirSync(dirname(still), { recursive: true });
|
|
323
|
+
writeFileSync(still, "png");
|
|
324
|
+
save("B");
|
|
325
|
+
await act(() =>
|
|
326
|
+
hook().recordEdit({
|
|
327
|
+
label: "Freeze frame",
|
|
328
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
329
|
+
created: ["assets/freeze/talk.png"],
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Freeze frame"));
|
|
333
|
+
|
|
334
|
+
await act(() => hook().undo({ readFile }));
|
|
335
|
+
expect(existsSync(still)).toBe(false);
|
|
336
|
+
|
|
337
|
+
await vi.waitFor(() => expect(hook().canRedo).toBe(true));
|
|
338
|
+
await act(() => hook().redo({ readFile }));
|
|
339
|
+
expect(readFileSync(still, "utf8")).toBe("png");
|
|
340
|
+
});
|
|
341
|
+
|
|
319
342
|
it("a drag's edits under one key undo as one step, even before the drag goes idle", async () => {
|
|
320
343
|
const { hook, file, save, readFile } = await studio();
|
|
321
344
|
const writes: Array<[before: string, after: string]> = [
|
|
@@ -3,13 +3,7 @@ import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server"
|
|
|
3
3
|
import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
|
|
4
4
|
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
5
5
|
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
6
|
-
|
|
7
|
-
interface RecordEditInput {
|
|
8
|
-
label: string;
|
|
9
|
-
coalesceKey?: string;
|
|
10
|
-
coalesceMs?: number;
|
|
11
|
-
files: Record<string, { before: string; after: string }>;
|
|
12
|
-
}
|
|
6
|
+
import type { RecordEditInput } from "../utils/studioFileHistory";
|
|
13
7
|
|
|
14
8
|
interface ApplyCallbacks {
|
|
15
9
|
readFile: (path: string) => Promise<string>;
|
|
@@ -235,9 +229,9 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
235
229
|
}, [refresh, own]);
|
|
236
230
|
|
|
237
231
|
const recordEdit = useCallback(
|
|
238
|
-
async ({ label, coalesceKey, coalesceMs, files }: RecordEditInput) => {
|
|
232
|
+
async ({ label, coalesceKey, coalesceMs, files, created = [] }: RecordEditInput) => {
|
|
239
233
|
if (!projectId) return;
|
|
240
|
-
const paths = Object.keys(files);
|
|
234
|
+
const paths = [...Object.keys(files), ...created];
|
|
241
235
|
own.overtake();
|
|
242
236
|
const reply = await post(historyUrl(projectId, "/claim"), {
|
|
243
237
|
label,
|
|
@@ -481,6 +481,45 @@ describe("applySoftReload authored-style restore", () => {
|
|
|
481
481
|
return el.style.getPropertyValue("opacity");
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
+
it("flushes only elements when a tween targets a plain object (the runtime's duration filler)", () => {
|
|
485
|
+
const el = document.createElement("div");
|
|
486
|
+
// GSAP's clearProps writes target.style.cssText, which throws on a plain object.
|
|
487
|
+
const set = vi.fn((targets: Array<{ style: CSSStyleDeclaration }>) => {
|
|
488
|
+
for (const t of targets) t.style.cssText = "";
|
|
489
|
+
});
|
|
490
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
491
|
+
gsap: { timeline: vi.fn(), set },
|
|
492
|
+
__timelines: {
|
|
493
|
+
root: {
|
|
494
|
+
kill: vi.fn(),
|
|
495
|
+
getChildren: () => [{ targets: () => [el] }, { targets: () => [{}] }],
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
|
|
501
|
+
expect(set).toHaveBeenCalledWith([el], { clearProps: "all" });
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("falls back to a full reload, and says why, when the flush throws", () => {
|
|
505
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
506
|
+
const { iframe } = buildIframeWithTarget(document.createElement("div"), {
|
|
507
|
+
gsap: {
|
|
508
|
+
timeline: vi.fn(),
|
|
509
|
+
set: vi.fn(() => {
|
|
510
|
+
throw new Error("flush failed");
|
|
511
|
+
}),
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
|
516
|
+
expect(error).toHaveBeenCalledWith(
|
|
517
|
+
expect.stringContaining("soft reload threw"),
|
|
518
|
+
expect.any(Error),
|
|
519
|
+
);
|
|
520
|
+
error.mockRestore();
|
|
521
|
+
});
|
|
522
|
+
|
|
484
523
|
it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
|
|
485
524
|
const el = document.createElement("img");
|
|
486
525
|
el.setAttribute("data-hf-id", "hf-1");
|
|
@@ -152,7 +152,8 @@ function runSuppressed(win: IframeWindow, reload: () => void): boolean {
|
|
|
152
152
|
if (win.__hfSuppressSceneMutations) win.__hfSuppressSceneMutations(reload);
|
|
153
153
|
else reload();
|
|
154
154
|
return true;
|
|
155
|
-
} catch {
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error("[Studio] GSAP soft reload threw; falling back to a full reload", error);
|
|
156
157
|
return false;
|
|
157
158
|
}
|
|
158
159
|
}
|
|
@@ -369,16 +370,11 @@ export function applySoftReload(
|
|
|
369
370
|
// HTML `style=""` attribute. Save → clear → restore → what GSAP wrote, from the file.
|
|
370
371
|
const allTargets = [...targets.keys()];
|
|
371
372
|
if (allTargets.length > 0 && win.gsap?.set) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]);
|
|
378
|
-
}
|
|
379
|
-
try {
|
|
380
|
-
win.gsap.set(allTargets, { clearProps: "all" });
|
|
381
|
-
} catch {}
|
|
373
|
+
// Iframe-realm nodes: instanceof HTMLElement fails across realms, so style access is duck-typed.
|
|
374
|
+
const saved = allTargets.map(
|
|
375
|
+
(el) => [el as HTMLElement, (el as HTMLElement).style.cssText] as const,
|
|
376
|
+
);
|
|
377
|
+
win.gsap.set(allTargets, { clearProps: "all" });
|
|
382
378
|
for (const [el, css] of saved) {
|
|
383
379
|
const s = el.style;
|
|
384
380
|
s.cssText = css;
|
|
@@ -16,11 +16,9 @@ const CROP = "inset(0px 40px 0px 0px)";
|
|
|
16
16
|
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
|
|
17
17
|
let browser: Browser;
|
|
18
18
|
let undoBundle: string;
|
|
19
|
+
let softReloadBundle: string;
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
const executablePath = findSystemChrome();
|
|
22
|
-
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
|
|
23
|
-
browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
|
21
|
+
async function bundle(file: string, name: string): Promise<string> {
|
|
24
22
|
const out = await build({
|
|
25
23
|
configFile: false,
|
|
26
24
|
logLevel: "silent",
|
|
@@ -30,68 +28,85 @@ beforeAll(async () => {
|
|
|
30
28
|
build: {
|
|
31
29
|
write: false,
|
|
32
30
|
minify: false,
|
|
33
|
-
lib: {
|
|
34
|
-
entry: fileURLToPath(new URL("./gsapUndoRestore.ts", import.meta.url)),
|
|
35
|
-
formats: ["iife"],
|
|
36
|
-
name: "hfUndo",
|
|
37
|
-
},
|
|
31
|
+
lib: { entry: fileURLToPath(new URL(file, import.meta.url)), formats: ["iife"], name },
|
|
38
32
|
},
|
|
39
33
|
});
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
return (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0].code;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
const executablePath = findSystemChrome();
|
|
39
|
+
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
|
|
40
|
+
browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
|
41
|
+
undoBundle = await bundle("./gsapUndoRestore.ts", "hfUndo");
|
|
42
|
+
softReloadBundle = await bundle("./gsapSoftReload.ts", "hfSoftReload");
|
|
42
43
|
});
|
|
43
44
|
|
|
44
45
|
afterAll(() => browser?.close());
|
|
45
46
|
|
|
47
|
+
type Spec = { gsap: string; placement: string; rotation: number; time: number };
|
|
48
|
+
|
|
49
|
+
/** The bench's root crop fixture, shown at `spec.time` with the module under test loaded. */
|
|
50
|
+
async function openCase(spec: Spec, moduleBundle: string) {
|
|
51
|
+
const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
|
|
52
|
+
writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
|
|
53
|
+
const restored = readFileSync(join(dir, "index.html"), "utf8");
|
|
54
|
+
const page = await browser.newPage();
|
|
55
|
+
await page.setViewport({ width: 1920, height: 1080 });
|
|
56
|
+
await page.setRequestInterception(true);
|
|
57
|
+
// Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
|
|
58
|
+
page.on("request", (request) =>
|
|
59
|
+
request.url().endsWith("/gsap.min.js")
|
|
60
|
+
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
|
|
61
|
+
: request.abort("blockedbyclient"),
|
|
62
|
+
);
|
|
63
|
+
await page.setContent(restored, { waitUntil: "load" });
|
|
64
|
+
await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
|
|
65
|
+
await page.waitForFunction(() => "__player" in window);
|
|
66
|
+
await page.evaluate(moduleBundle);
|
|
67
|
+
await page.evaluate(
|
|
68
|
+
(time) => (window as unknown as { __player: { seek(t: number): void } }).__player.seek(time),
|
|
69
|
+
spec.time,
|
|
70
|
+
);
|
|
71
|
+
const box = () =>
|
|
72
|
+
page.evaluate(() => {
|
|
73
|
+
const { left, top, width, height } = document
|
|
74
|
+
.getElementById("target")!
|
|
75
|
+
.getBoundingClientRect();
|
|
76
|
+
return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
|
|
77
|
+
});
|
|
78
|
+
return { page, restored, box };
|
|
79
|
+
}
|
|
80
|
+
|
|
46
81
|
// The first case opens the cold browser's first page, which overran 5 s on a loaded CI runner.
|
|
47
82
|
it.each([
|
|
48
|
-
{ gsap: "hold", placement: "px", rotation: 0 },
|
|
49
|
-
{ gsap: "tween", placement: "px", rotation: 0 },
|
|
50
|
-
{ gsap: "tween", placement: "xpercent", rotation: 30 },
|
|
83
|
+
{ gsap: "hold", placement: "px", rotation: 0, time: 1 },
|
|
84
|
+
{ gsap: "tween", placement: "px", rotation: 0, time: 1 },
|
|
85
|
+
{ gsap: "tween", placement: "xpercent", rotation: 30, time: 1 },
|
|
86
|
+
{ gsap: "keys", placement: "px", rotation: 0, time: 1 },
|
|
87
|
+
{ gsap: "keys", placement: "px", rotation: 30, time: 2 },
|
|
88
|
+
{ gsap: "scale", placement: "px", rotation: 0, time: 1 },
|
|
89
|
+
{ gsap: "spin", placement: "px", rotation: 30, time: 1 },
|
|
90
|
+
{ gsap: "fromto", placement: "px", rotation: 0, time: 1 },
|
|
51
91
|
])(
|
|
52
|
-
"an undone crop leaves a $gsap $placement r$rotation layer where it was",
|
|
92
|
+
"an undone crop leaves a $gsap $placement r$rotation layer at t$time where it was",
|
|
53
93
|
async (spec) => {
|
|
54
|
-
const
|
|
55
|
-
writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
|
|
56
|
-
const restored = readFileSync(join(dir, "index.html"), "utf8");
|
|
94
|
+
const { page, restored, box } = await openCase(spec, undoBundle);
|
|
57
95
|
const previous = restored.replace(
|
|
58
96
|
`<div id="target"`,
|
|
59
97
|
`<div id="target" style="clip-path: ${CROP}"`,
|
|
60
98
|
);
|
|
61
|
-
const page = await browser.newPage();
|
|
62
|
-
await page.setViewport({ width: 1920, height: 1080 });
|
|
63
|
-
await page.setRequestInterception(true);
|
|
64
|
-
// Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
|
|
65
|
-
page.on("request", (request) =>
|
|
66
|
-
request.url().endsWith("/gsap.min.js")
|
|
67
|
-
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
|
|
68
|
-
: request.abort("blockedbyclient"),
|
|
69
|
-
);
|
|
70
|
-
await page.setContent(restored, { waitUntil: "load" });
|
|
71
|
-
await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
|
|
72
|
-
await page.waitForFunction(() => "__player" in window);
|
|
73
|
-
await page.evaluate(undoBundle);
|
|
74
|
-
|
|
75
|
-
const box = () =>
|
|
76
|
-
page.evaluate(() => {
|
|
77
|
-
const { left, top, width, height } = document
|
|
78
|
-
.getElementById("target")!
|
|
79
|
-
.getBoundingClientRect();
|
|
80
|
-
return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
|
|
81
|
-
});
|
|
82
|
-
await page.evaluate(() =>
|
|
83
|
-
(window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
|
|
84
|
-
);
|
|
85
99
|
const before = await box();
|
|
86
100
|
const outcome = await page.evaluate(
|
|
87
|
-
(files, crop) => {
|
|
101
|
+
(files, crop, time) => {
|
|
88
102
|
document.getElementById("target")!.style.setProperty("clip-path", crop);
|
|
89
103
|
const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
|
|
90
104
|
const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
|
|
91
|
-
return undo.applyUndoRestoreToPreview(iframe, "index.html", files,
|
|
105
|
+
return undo.applyUndoRestoreToPreview(iframe, "index.html", files, time, () => {});
|
|
92
106
|
},
|
|
93
107
|
{ "index.html": { previous, restored } },
|
|
94
108
|
CROP,
|
|
109
|
+
spec.time,
|
|
95
110
|
);
|
|
96
111
|
|
|
97
112
|
expect(outcome).toBe("soft");
|
|
@@ -100,3 +115,35 @@ it.each([
|
|
|
100
115
|
},
|
|
101
116
|
30_000,
|
|
102
117
|
);
|
|
118
|
+
|
|
119
|
+
// A drop saves the file and soft-reloads it; the re-run must place an animated layer where it was.
|
|
120
|
+
it.each([
|
|
121
|
+
{ gsap: "tween", placement: "px", rotation: 0, time: 1 },
|
|
122
|
+
{ gsap: "keys", placement: "px", rotation: 30, time: 2 },
|
|
123
|
+
{ gsap: "spin", placement: "px", rotation: 0, time: 1 },
|
|
124
|
+
])(
|
|
125
|
+
"a soft reload keeps a $gsap $placement r$rotation layer at t$time where it was",
|
|
126
|
+
async (spec) => {
|
|
127
|
+
const { page, restored, box } = await openCase(spec, softReloadBundle);
|
|
128
|
+
const before = await box();
|
|
129
|
+
const outcome = await page.evaluate(
|
|
130
|
+
(html, time) => {
|
|
131
|
+
const soft = (window as unknown as { hfSoftReload: typeof import("./gsapSoftReload") })
|
|
132
|
+
.hfSoftReload;
|
|
133
|
+
const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
|
|
134
|
+
const script = soft.extractGsapScriptText(html)!;
|
|
135
|
+
return soft.applySoftReload(iframe, script, {
|
|
136
|
+
currentTimeOverride: time,
|
|
137
|
+
authoredHtml: html,
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
restored,
|
|
141
|
+
spec.time,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(outcome).toBe("applied");
|
|
145
|
+
expect(await box()).toEqual(before);
|
|
146
|
+
await page.close();
|
|
147
|
+
},
|
|
148
|
+
30_000,
|
|
149
|
+
);
|