@hyperframes/studio 0.8.98 → 0.8.99
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-C6muFtfk.js → hyperframes-player-Rml34FRW.js} +1 -1
- package/dist/assets/{index-itc1XziP.js → index-CVDArg3m.js} +178 -178
- package/dist/assets/{index-B6vU39NM.js → index-C_fi-jTe.js} +1 -1
- package/dist/assets/{index-BxnoLgxG.js → index-D9YzDuBx.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +542 -373
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
- package/src/components/editor/DomEditCropHandles.tsx +30 -62
- package/src/components/editor/DomEditOverlay.test.ts +2 -2
- package/src/components/editor/cropResize.test.ts +129 -0
- package/src/components/editor/cropResize.ts +143 -0
- package/src/components/editor/domEditOverlayCrop.ts +65 -15
- package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
- package/src/components/editor/domEditOverlayGestures.ts +2 -5
- package/src/components/editor/elementPositionOffset.ts +5 -4
- package/src/components/editor/manualEdits.test.ts +22 -8
- package/src/components/editor/manualEditsDom.ts +27 -11
- package/src/components/editor/manualEditsParsing.ts +0 -4
- package/src/components/editor/manualEditsSnapshot.ts +3 -0
- package/src/components/editor/manualOffsetDrag.test.ts +14 -0
- package/src/hooks/domSelectionTestHarness.ts +8 -0
- package/src/hooks/draggedGsapPosition.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +6 -6
- package/src/hooks/gsapEditOutcome.ts +6 -0
- package/src/hooks/gsapResizeIntercept.ts +5 -5
- package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
- package/src/hooks/gsapRuntimeBridge.ts +2 -1
- package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
- package/src/hooks/timelineTimingSync.ts +40 -4
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
- package/src/hooks/useCropOverlay.ts +8 -2
- package/src/hooks/useDomEditCommits.test.tsx +6 -1
- package/src/hooks/useDomEditCommits.ts +1 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.ts +1 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
- package/src/hooks/useDomGeometryCommits.ts +5 -0
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
- package/src/hooks/useGsapAwareEditing.ts +37 -23
- package/src/hooks/useTimelineEditing.test.tsx +190 -135
- package/src/hooks/useTimelineEditing.ts +13 -13
- package/src/hooks/useTimelineGroupEditing.ts +13 -9
- package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
- package/src/utils/rounding.ts +5 -0
- package/src/utils/sdkEditTransaction.ts +10 -3
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
7
|
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
8
|
-
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
8
|
+
import { mountReactHarness, withInlineLayoutBox } from "./domSelectionTestHarness";
|
|
9
|
+
import { DomEditCropHandles } from "../components/editor/DomEditCropHandles";
|
|
10
|
+
import { applyStudioBoxSizeDraft } from "../components/editor/manualEdits";
|
|
9
11
|
|
|
10
12
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
13
|
|
|
@@ -56,6 +58,7 @@ function mountResizeHandler(
|
|
|
56
58
|
const anchorRollback = vi.fn();
|
|
57
59
|
const elementOffset = vi.fn(() => ({ save: anchorSave, rollback: anchorRollback }));
|
|
58
60
|
const commitMutation = vi.fn().mockResolvedValue(undefined);
|
|
61
|
+
const commitPatch = vi.fn().mockResolvedValue(undefined);
|
|
59
62
|
let resize:
|
|
60
63
|
| ((
|
|
61
64
|
selection: DomEditSelection,
|
|
@@ -64,8 +67,9 @@ function mountResizeHandler(
|
|
|
64
67
|
restore?: () => void,
|
|
65
68
|
) => Promise<void>)
|
|
66
69
|
| null = null;
|
|
70
|
+
let property: ReturnType<typeof useGsapAwareEditing>["commitAnimatedProperty"] | null = null;
|
|
67
71
|
function Harness() {
|
|
68
|
-
|
|
72
|
+
const editing = useGsapAwareEditing({
|
|
69
73
|
domEditSelection: selection,
|
|
70
74
|
selectedGsapAnimations: animations,
|
|
71
75
|
gsapCommitMutation: commitMutation,
|
|
@@ -76,11 +80,14 @@ function mountResizeHandler(
|
|
|
76
80
|
trackGsapInteractionFailure: vi.fn(),
|
|
77
81
|
stageElementPositionOffset: elementOffset,
|
|
78
82
|
handleDomBoxSizeCommit: fallback,
|
|
83
|
+
commitPositionPatchToHtml: commitPatch,
|
|
79
84
|
addGsapAnimation: vi.fn(),
|
|
80
85
|
convertToKeyframes: vi.fn(),
|
|
81
86
|
setArcPath: vi.fn(),
|
|
82
87
|
updateArcSegment: vi.fn(),
|
|
83
|
-
})
|
|
88
|
+
});
|
|
89
|
+
resize = editing.handleGsapAwareBoxSizeCommit;
|
|
90
|
+
property = editing.commitAnimatedProperty;
|
|
84
91
|
return null;
|
|
85
92
|
}
|
|
86
93
|
const root = mountReactHarness(<Harness />);
|
|
@@ -91,7 +98,9 @@ function mountResizeHandler(
|
|
|
91
98
|
anchorSave,
|
|
92
99
|
anchorRollback,
|
|
93
100
|
commitMutation,
|
|
101
|
+
commitPatch,
|
|
94
102
|
resize: resize!,
|
|
103
|
+
property: property!,
|
|
95
104
|
root,
|
|
96
105
|
};
|
|
97
106
|
}
|
|
@@ -122,6 +131,7 @@ function mountGroupHandler({
|
|
|
122
131
|
trackGsapInteractionFailure,
|
|
123
132
|
stageElementPositionOffset,
|
|
124
133
|
handleDomBoxSizeCommit: vi.fn(),
|
|
134
|
+
commitPositionPatchToHtml: vi.fn(),
|
|
125
135
|
addGsapAnimation: vi.fn(),
|
|
126
136
|
convertToKeyframes: vi.fn(),
|
|
127
137
|
setArcPath: vi.fn(),
|
|
@@ -257,6 +267,92 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
257
267
|
act(() => h.root.unmount());
|
|
258
268
|
});
|
|
259
269
|
|
|
270
|
+
function mountCropped() {
|
|
271
|
+
const h = mountResizeHandler([]);
|
|
272
|
+
const el = withInlineLayoutBox(h.selection.element);
|
|
273
|
+
el.id = "clip";
|
|
274
|
+
el.style.cssText = "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)";
|
|
275
|
+
document.body.append(el);
|
|
276
|
+
const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 };
|
|
277
|
+
const crop = mountReactHarness(
|
|
278
|
+
<DomEditCropHandles selection={h.selection} overlayRect={rect} onStyleCommit={vi.fn()} />,
|
|
279
|
+
);
|
|
280
|
+
return { ...h, el, crop };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function resizeLandsWhen(where: "intercept" | "dispatch", h: { commitMutation: Mock }) {
|
|
284
|
+
const land = (el: HTMLElement) => {
|
|
285
|
+
el.style.width = "450px";
|
|
286
|
+
el.style.height = "300px";
|
|
287
|
+
};
|
|
288
|
+
mocks.resize.mockImplementation(async (selection, _next, _a, _i, commit) => {
|
|
289
|
+
if (where === "intercept") land(selection.element);
|
|
290
|
+
await commit(selection, { type: "size" }, { label: "Resize", softReload: true });
|
|
291
|
+
return { status: "persisted" };
|
|
292
|
+
});
|
|
293
|
+
if (where === "dispatch") h.commitMutation.mockImplementation(async (s) => land(s.element));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const scaled = "inset(0px 90px 0px 0px)";
|
|
297
|
+
|
|
298
|
+
it("saves the crop scaled with a size that lands only when saved (the W/H fields)", async () => {
|
|
299
|
+
const h = mountCropped();
|
|
300
|
+
resizeLandsWhen("dispatch", h);
|
|
301
|
+
await act(() => h.resize(h.selection, { width: 450, height: 300 }));
|
|
302
|
+
|
|
303
|
+
expect(h.commitPatch).toHaveBeenCalledWith(
|
|
304
|
+
h.selection,
|
|
305
|
+
[{ type: "inline-style", property: "clip-path", value: scaled }],
|
|
306
|
+
expect.objectContaining({ coalesceKey: h.commitMutation.mock.calls[0]![2].coalesceKey }),
|
|
307
|
+
);
|
|
308
|
+
act(() => h.crop.unmount());
|
|
309
|
+
expect(h.el.style.getPropertyValue("clip-path")).toBe(scaled);
|
|
310
|
+
act(() => h.root.unmount());
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("scales from the box before a draft its caller applied (the agent tool)", async () => {
|
|
314
|
+
const h = mountCropped();
|
|
315
|
+
resizeLandsWhen("intercept", h);
|
|
316
|
+
applyStudioBoxSizeDraft(h.el, { width: 450, height: 300 });
|
|
317
|
+
await act(() => h.resize(h.selection, { width: 450, height: 300 }));
|
|
318
|
+
|
|
319
|
+
expect(h.commitPatch.mock.calls[0]![1]).toEqual([
|
|
320
|
+
{ type: "inline-style", property: "clip-path", value: scaled },
|
|
321
|
+
]);
|
|
322
|
+
act(() => h.crop.unmount());
|
|
323
|
+
act(() => h.root.unmount());
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("a W field on an animated element saves the scaled crop in the same undo step", async () => {
|
|
327
|
+
const h = mountCropped();
|
|
328
|
+
mocks.commitAnimatedProperties.mockImplementation(async (selection, _props, keyed) => {
|
|
329
|
+
await (keyed ?? h.commitMutation)(selection, { type: "set" }, { label: "Edit width" });
|
|
330
|
+
selection.element.style.width = "450px";
|
|
331
|
+
});
|
|
332
|
+
await act(() => h.property(h.selection, "width", 450));
|
|
333
|
+
|
|
334
|
+
const key = h.commitMutation.mock.calls[0]![2].coalesceKey;
|
|
335
|
+
expect(key).toBeTruthy();
|
|
336
|
+
expect(h.commitPatch).toHaveBeenCalledWith(
|
|
337
|
+
h.selection,
|
|
338
|
+
[{ type: "inline-style", property: "clip-path", value: "inset(0px 90px 0px 0px)" }],
|
|
339
|
+
expect.objectContaining({ coalesceKey: key }),
|
|
340
|
+
);
|
|
341
|
+
act(() => h.crop.unmount());
|
|
342
|
+
act(() => h.root.unmount());
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("shows the scaled crop when deselected before the save lands", async () => {
|
|
346
|
+
const h = mountCropped();
|
|
347
|
+
resizeLandsWhen("intercept", h);
|
|
348
|
+
const saved = h.resize(h.selection, { width: 450, height: 300 });
|
|
349
|
+
act(() => h.crop.unmount());
|
|
350
|
+
await act(() => saved);
|
|
351
|
+
|
|
352
|
+
expect(h.el.style.getPropertyValue("clip-path")).toBe(scaled);
|
|
353
|
+
act(() => h.root.unmount());
|
|
354
|
+
});
|
|
355
|
+
|
|
260
356
|
it("does not keep the anchor move when the size save fails", async () => {
|
|
261
357
|
resizeWritesSize();
|
|
262
358
|
const h = mountResizeHandler([]);
|
|
@@ -288,7 +384,7 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
288
384
|
commit = h.resize(h.selection, { width: 300, height: 200 }, { x: -50.2, y: -25.6 });
|
|
289
385
|
});
|
|
290
386
|
|
|
291
|
-
expect(mocks.setPosition).toHaveBeenCalledWith(h.selection.element, 70,
|
|
387
|
+
expect(mocks.setPosition).toHaveBeenCalledWith(h.selection.element, 70.2, 54.6);
|
|
292
388
|
expect(mocks.setPosition.mock.invocationCallOrder[0]).toBeLessThan(
|
|
293
389
|
mocks.resize.mock.invocationCallOrder[0]!,
|
|
294
390
|
);
|
|
@@ -34,14 +34,14 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
|
|
|
34
34
|
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
35
35
|
import { runGestureTransaction } from "./gestureTransaction";
|
|
36
36
|
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
37
|
-
import { assertGsapEditPersisted,
|
|
37
|
+
import { assertGsapEditPersisted, saveMove } from "./gsapEditOutcome";
|
|
38
38
|
import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
39
|
+
import type { ElementOffsetStagerDeps } from "./elementOffsetStager";
|
|
40
|
+
import {
|
|
41
|
+
prepareCropResize,
|
|
42
|
+
saveCropResize,
|
|
43
|
+
writeSizeWithCrop,
|
|
44
|
+
} from "../components/editor/cropResize";
|
|
45
45
|
|
|
46
46
|
// Distinct coalesceKey per group drag so consecutive group drags don't fold
|
|
47
47
|
// into one another's undo entry (module-local counter, not Date.now()).
|
|
@@ -87,6 +87,7 @@ export interface UseGsapAwareEditingParams {
|
|
|
87
87
|
next: { width: number; height: number },
|
|
88
88
|
offset?: { x: number; y: number },
|
|
89
89
|
) => Promise<void>;
|
|
90
|
+
commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"];
|
|
90
91
|
// GSAP script commit ops (from useGsapScriptCommits)
|
|
91
92
|
addGsapAnimation: (
|
|
92
93
|
sel: DomEditSelection,
|
|
@@ -130,6 +131,7 @@ export function useGsapAwareEditing({
|
|
|
130
131
|
trackGsapInteractionFailure,
|
|
131
132
|
stageElementPositionOffset,
|
|
132
133
|
handleDomBoxSizeCommit,
|
|
134
|
+
commitPositionPatchToHtml,
|
|
133
135
|
addGsapAnimation,
|
|
134
136
|
convertToKeyframes,
|
|
135
137
|
setArcPath,
|
|
@@ -353,6 +355,8 @@ export function useGsapAwareEditing({
|
|
|
353
355
|
animGroups: targetAnimations.map((a) => `${a.propertyGroup}:${a.method}`),
|
|
354
356
|
});
|
|
355
357
|
let anchorMove: ReturnType<typeof stageElementPositionOffset> | null = null;
|
|
358
|
+
const stageCrop = prepareCropResize(selection.element);
|
|
359
|
+
let cropUndoKey: string | null = null;
|
|
356
360
|
return runGestureTransaction({
|
|
357
361
|
element: selection.element,
|
|
358
362
|
label: "Resize layer",
|
|
@@ -381,6 +385,7 @@ export function useGsapAwareEditing({
|
|
|
381
385
|
makeFetchFallback(selection),
|
|
382
386
|
);
|
|
383
387
|
assertGsapEditPersisted(outcome);
|
|
388
|
+
cropUndoKey = coalesceKey;
|
|
384
389
|
// What the resize actually did, not what its animations suggest
|
|
385
390
|
// it would do. An element whose scale is an instant hold has a
|
|
386
391
|
// scale-group tween and still commits width/height, so guessing
|
|
@@ -424,7 +429,13 @@ export function useGsapAwareEditing({
|
|
|
424
429
|
logResizeSettle(selection.element, "dom-route");
|
|
425
430
|
await handleDomBoxSizeCommit(selection, next, offset);
|
|
426
431
|
},
|
|
427
|
-
afterBufferedCommitsSaved: () =>
|
|
432
|
+
afterBufferedCommitsSaved: async () => {
|
|
433
|
+
await anchorMove?.save();
|
|
434
|
+
// Only now is the size live for every caller, drag or not.
|
|
435
|
+
if (cropUndoKey) {
|
|
436
|
+
await saveCropResize(stageCrop, selection, commitPositionPatchToHtml, cropUndoKey);
|
|
437
|
+
}
|
|
438
|
+
},
|
|
428
439
|
restore: () => {
|
|
429
440
|
anchorMove?.rollback();
|
|
430
441
|
restore();
|
|
@@ -434,6 +445,7 @@ export function useGsapAwareEditing({
|
|
|
434
445
|
},
|
|
435
446
|
[
|
|
436
447
|
handleDomBoxSizeCommit,
|
|
448
|
+
commitPositionPatchToHtml,
|
|
437
449
|
stageElementPositionOffset,
|
|
438
450
|
gsapCommitMutation,
|
|
439
451
|
previewIframeRef,
|
|
@@ -481,10 +493,7 @@ export function useGsapAwareEditing({
|
|
|
481
493
|
|
|
482
494
|
// ── Animated property commit ──
|
|
483
495
|
|
|
484
|
-
const {
|
|
485
|
-
commitAnimatedProperty: commitAnimatedPropertyRaw,
|
|
486
|
-
commitAnimatedProperties: commitAnimatedPropertiesRaw,
|
|
487
|
-
} = useAnimatedPropertyCommit({
|
|
496
|
+
const { commitAnimatedProperties: commitAnimatedPropertiesRaw } = useAnimatedPropertyCommit({
|
|
488
497
|
selectedGsapAnimations,
|
|
489
498
|
gsapCommitMutation,
|
|
490
499
|
addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
|
|
@@ -496,25 +505,30 @@ export function useGsapAwareEditing({
|
|
|
496
505
|
const commitAnimatedProperties = useCallback(
|
|
497
506
|
async (selection: DomEditSelection, properties: Record<string, number | string>) => {
|
|
498
507
|
try {
|
|
499
|
-
await
|
|
508
|
+
await writeSizeWithCrop(
|
|
509
|
+
selection,
|
|
510
|
+
properties,
|
|
511
|
+
gsapCommitMutation,
|
|
512
|
+
commitPositionPatchToHtml,
|
|
513
|
+
(keyed) => commitAnimatedPropertiesRaw(selection, properties, keyed),
|
|
514
|
+
);
|
|
500
515
|
} catch (error) {
|
|
501
516
|
trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
|
|
502
517
|
throw error;
|
|
503
518
|
}
|
|
504
519
|
},
|
|
505
|
-
[
|
|
520
|
+
[
|
|
521
|
+
commitAnimatedPropertiesRaw,
|
|
522
|
+
commitPositionPatchToHtml,
|
|
523
|
+
gsapCommitMutation,
|
|
524
|
+
trackGsapInteractionFailure,
|
|
525
|
+
],
|
|
506
526
|
);
|
|
507
527
|
|
|
508
528
|
const commitAnimatedProperty = useCallback(
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
} catch (error) {
|
|
513
|
-
trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
|
|
514
|
-
throw error;
|
|
515
|
-
}
|
|
516
|
-
},
|
|
517
|
-
[commitAnimatedPropertyRaw, trackGsapInteractionFailure],
|
|
529
|
+
(selection: DomEditSelection, property: string, value: number | string) =>
|
|
530
|
+
commitAnimatedProperties(selection, { [property]: value }),
|
|
531
|
+
[commitAnimatedProperties],
|
|
518
532
|
);
|
|
519
533
|
|
|
520
534
|
// ── Arc path wrappers ──
|
|
@@ -21,6 +21,10 @@ import {
|
|
|
21
21
|
resolveTimelineGroupResizeChanges,
|
|
22
22
|
} from "../player/components/timelineGroupEditing";
|
|
23
23
|
import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
|
|
24
|
+
import {
|
|
25
|
+
scalePositionsInScript,
|
|
26
|
+
shiftPositionsInScript,
|
|
27
|
+
} from "@hyperframes/core/gsap-writer-acorn";
|
|
24
28
|
|
|
25
29
|
vi.mock("../components/editor/manualEditingAvailability", async (importOriginal) => {
|
|
26
30
|
const actual =
|
|
@@ -134,6 +138,7 @@ function renderTimelineEditingHook(input: {
|
|
|
134
138
|
invalidateGsapCache?: () => void;
|
|
135
139
|
showToast?: (message: string, kind?: string) => void;
|
|
136
140
|
canEdit?: NonNullable<Parameters<typeof useTimelineEditing>[0]["canEdit"]>;
|
|
141
|
+
activeCompPath?: string;
|
|
137
142
|
}): {
|
|
138
143
|
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
|
139
144
|
resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
|
|
@@ -166,7 +171,7 @@ function renderTimelineEditingHook(input: {
|
|
|
166
171
|
commitRef.current = input.onZIndexCommit;
|
|
167
172
|
const hook = useTimelineEditing({
|
|
168
173
|
projectId: input.projectId ?? null,
|
|
169
|
-
activeCompPath: "index.html",
|
|
174
|
+
activeCompPath: input.activeCompPath ?? "index.html",
|
|
170
175
|
timelineElements: input.timelineElements,
|
|
171
176
|
showToast: input.showToast ?? vi.fn(),
|
|
172
177
|
writeProjectFile: input.writeProjectFile ?? vi.fn(),
|
|
@@ -372,39 +377,6 @@ function setupSingleClipHarness(options?: {
|
|
|
372
377
|
};
|
|
373
378
|
}
|
|
374
379
|
|
|
375
|
-
const SDK_KEYFRAMED_SOURCE = [
|
|
376
|
-
`<div data-hf-id="hf-stage" data-hf-root data-composition-id="main" data-duration="10">`,
|
|
377
|
-
` <div id="clip" data-hf-id="hf-clip" data-start="1" data-duration="2"></div>`,
|
|
378
|
-
`</div>`,
|
|
379
|
-
`<script>`,
|
|
380
|
-
`const tl = gsap.timeline({ paused: true });`,
|
|
381
|
-
`tl.to("#clip", { keyframes: [{ x: 0 }, { x: 100 }], duration: 2 }, 1);`,
|
|
382
|
-
`window.__timelines = [tl];`,
|
|
383
|
-
`</script>`,
|
|
384
|
-
].join("\n");
|
|
385
|
-
|
|
386
|
-
async function setupSdkKeyframedClipHarness() {
|
|
387
|
-
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
388
|
-
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 1 });
|
|
389
|
-
const sdkSession = await openComposition(SDK_KEYFRAMED_SOURCE);
|
|
390
|
-
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
391
|
-
const invalidateGsapCache = vi.fn();
|
|
392
|
-
const fetchMock = stubProjectFetch(SDK_KEYFRAMED_SOURCE);
|
|
393
|
-
usePlayerStore.getState().setDuration(10);
|
|
394
|
-
const hook = renderTimelineEditingHook({
|
|
395
|
-
timelineElements: [clip],
|
|
396
|
-
iframe,
|
|
397
|
-
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
398
|
-
projectId: "p1",
|
|
399
|
-
writeProjectFile,
|
|
400
|
-
recordEdit: vi.fn(async () => {}),
|
|
401
|
-
sdkSession,
|
|
402
|
-
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
|
|
403
|
-
invalidateGsapCache,
|
|
404
|
-
});
|
|
405
|
-
return { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile };
|
|
406
|
-
}
|
|
407
|
-
|
|
408
380
|
/** Assert a lane write landed in both the live iframe DOM and the persisted file. */
|
|
409
381
|
function expectLanePersisted(
|
|
410
382
|
iframe: HTMLIFrameElement,
|
|
@@ -830,58 +802,6 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
830
802
|
h.unmount();
|
|
831
803
|
});
|
|
832
804
|
|
|
833
|
-
it("shifts authored GSAP positions after an SDK-backed clip move commits", async () => {
|
|
834
|
-
const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
|
|
835
|
-
await setupSdkKeyframedClipHarness();
|
|
836
|
-
|
|
837
|
-
await act(async () => {
|
|
838
|
-
await hook.move(clip, { start: 2.25, track: clip.track });
|
|
839
|
-
});
|
|
840
|
-
|
|
841
|
-
expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2.25"');
|
|
842
|
-
const mutationCall = fetchMock.mock.calls.find((call) =>
|
|
843
|
-
requestUrl(call[0]).includes("/gsap-mutations/"),
|
|
844
|
-
);
|
|
845
|
-
expect(mutationCall).toBeDefined();
|
|
846
|
-
const init = mutationCall?.[1] as RequestInit | undefined;
|
|
847
|
-
expect(JSON.parse(String(init?.body))).toEqual({
|
|
848
|
-
type: "shift-positions",
|
|
849
|
-
targetSelector: "#clip",
|
|
850
|
-
delta: 1.25,
|
|
851
|
-
});
|
|
852
|
-
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
|
|
853
|
-
|
|
854
|
-
hook.unmount();
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
it("scales authored GSAP positions after an SDK-backed clip resize commits", async () => {
|
|
858
|
-
const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
|
|
859
|
-
await setupSdkKeyframedClipHarness();
|
|
860
|
-
|
|
861
|
-
await act(async () => {
|
|
862
|
-
await hook.resize(clip, { start: 2, duration: 4, playbackStart: undefined });
|
|
863
|
-
});
|
|
864
|
-
|
|
865
|
-
expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2"');
|
|
866
|
-
expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-duration="4"');
|
|
867
|
-
const mutationCall = fetchMock.mock.calls.find((call) =>
|
|
868
|
-
requestUrl(call[0]).includes("/gsap-mutations/"),
|
|
869
|
-
);
|
|
870
|
-
expect(mutationCall).toBeDefined();
|
|
871
|
-
const init = mutationCall?.[1] as RequestInit | undefined;
|
|
872
|
-
expect(JSON.parse(String(init?.body))).toEqual({
|
|
873
|
-
type: "scale-positions",
|
|
874
|
-
targetSelector: "#clip",
|
|
875
|
-
oldStart: 1,
|
|
876
|
-
oldDuration: 2,
|
|
877
|
-
newStart: 2,
|
|
878
|
-
newDuration: 4,
|
|
879
|
-
});
|
|
880
|
-
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
|
|
881
|
-
|
|
882
|
-
hook.unmount();
|
|
883
|
-
});
|
|
884
|
-
|
|
885
805
|
it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
|
|
886
806
|
// Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
|
|
887
807
|
// pure lane change routed through onMoveElement (no onMoveElements wired)
|
|
@@ -993,55 +913,6 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
993
913
|
unmount();
|
|
994
914
|
});
|
|
995
915
|
|
|
996
|
-
it("shifts every keyed clip and invalidates the cache after an SDK-backed group move", async () => {
|
|
997
|
-
const source = [
|
|
998
|
-
`<div data-hf-id="hf-stage" data-hf-root data-duration="10">`,
|
|
999
|
-
` <div id="a" data-hf-id="hf-a" data-start="0" data-duration="1"></div>`,
|
|
1000
|
-
` <div id="b" data-hf-id="hf-b" data-start="1" data-duration="1"></div>`,
|
|
1001
|
-
`</div>`,
|
|
1002
|
-
`<script>`,
|
|
1003
|
-
`const tl = gsap.timeline({ paused: true });`,
|
|
1004
|
-
`tl.to("#a", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 0);`,
|
|
1005
|
-
`tl.to("#b", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 1);`,
|
|
1006
|
-
`window.__timelines = [tl];`,
|
|
1007
|
-
`</script>`,
|
|
1008
|
-
].join("\n");
|
|
1009
|
-
const { iframe, a, b } = makeTwoClipPair();
|
|
1010
|
-
const sdkSession = await openComposition(source);
|
|
1011
|
-
const fetchMock = stubProjectFetch(source);
|
|
1012
|
-
const invalidateGsapCache = vi.fn();
|
|
1013
|
-
usePlayerStore.getState().setDuration(10);
|
|
1014
|
-
const hook = renderTimelineEditingHook({
|
|
1015
|
-
timelineElements: [a, b],
|
|
1016
|
-
iframe,
|
|
1017
|
-
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
1018
|
-
projectId: "p1",
|
|
1019
|
-
writeProjectFile: vi.fn<(...args: unknown[]) => Promise<void>>(async () => {}),
|
|
1020
|
-
recordEdit: vi.fn(async () => {}),
|
|
1021
|
-
sdkSession,
|
|
1022
|
-
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
|
|
1023
|
-
invalidateGsapCache,
|
|
1024
|
-
});
|
|
1025
|
-
|
|
1026
|
-
await act(async () => {
|
|
1027
|
-
await hook.groupMove([
|
|
1028
|
-
{ element: a, start: 1 },
|
|
1029
|
-
{ element: b, start: 2 },
|
|
1030
|
-
]);
|
|
1031
|
-
});
|
|
1032
|
-
|
|
1033
|
-
const mutations = fetchMock.mock.calls
|
|
1034
|
-
.filter((call) => requestUrl(call[0]).includes("/gsap-mutations/"))
|
|
1035
|
-
.map((call) => JSON.parse(String((call[1] as RequestInit | undefined)?.body)));
|
|
1036
|
-
expect(mutations).toEqual([
|
|
1037
|
-
{ type: "shift-positions", targetSelector: "#a", delta: 1 },
|
|
1038
|
-
{ type: "shift-positions", targetSelector: "#b", delta: 1 },
|
|
1039
|
-
]);
|
|
1040
|
-
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
|
|
1041
|
-
|
|
1042
|
-
hook.unmount();
|
|
1043
|
-
});
|
|
1044
|
-
|
|
1045
916
|
it("partitions a group move by source file while keeping one undo entry", async () => {
|
|
1046
917
|
const files: Record<string, string> = {
|
|
1047
918
|
"index.html": '<div id="a" data-start="0" data-duration="1"></div>',
|
|
@@ -2353,3 +2224,187 @@ describe("useTimelineEditing: nested rows write composition-local starts", () =>
|
|
|
2353
2224
|
hook.unmount();
|
|
2354
2225
|
});
|
|
2355
2226
|
});
|
|
2227
|
+
|
|
2228
|
+
describe("clip timing edits sync GSAP exactly once", () => {
|
|
2229
|
+
const SCENE_PATH = "compositions/scene.html";
|
|
2230
|
+
const SCENE_SOURCE = [
|
|
2231
|
+
`<div data-hf-id="hf-stage" data-hf-root data-composition-id="scene" data-duration="10">`,
|
|
2232
|
+
` <div id="scene" data-hf-id="hf-scene" data-start="1" data-duration="4"><h1 data-hf-id="hf-title">Hi</h1></div>`,
|
|
2233
|
+
` <div id="side" data-hf-id="hf-side" data-start="0" data-duration="8"></div>`,
|
|
2234
|
+
`</div>`,
|
|
2235
|
+
`<script>`,
|
|
2236
|
+
`const tl = gsap.timeline({ paused: true });`,
|
|
2237
|
+
`tl.to("#scene", { x: 1, duration: 1 }, 1);`,
|
|
2238
|
+
`tl.from("#scene h1", { y: 20, duration: 1 }, 1.5);`,
|
|
2239
|
+
`tl.to("#side", { x: 5, duration: 1 }, 2);`,
|
|
2240
|
+
`window.__timelines = [tl];`,
|
|
2241
|
+
`</script>`,
|
|
2242
|
+
].join("\n");
|
|
2243
|
+
|
|
2244
|
+
// A project whose files live in memory, with the GSAP route running the real server writer on them.
|
|
2245
|
+
function stubProjectFiles(initial: Record<string, string>) {
|
|
2246
|
+
const files = { ...initial };
|
|
2247
|
+
const pathAfter = (url: string, marker: string) => decodeURIComponent(url.split(marker)[1]!);
|
|
2248
|
+
const applyServerMutation = (path: string, body: Record<string, unknown>) => {
|
|
2249
|
+
const before = files[path]!;
|
|
2250
|
+
const doc = new DOMParser().parseFromString(before, "text/html");
|
|
2251
|
+
const root = doc.querySelector("template")?.content ?? doc;
|
|
2252
|
+
const old = [...root.querySelectorAll("script")]
|
|
2253
|
+
.map((script) => script.textContent ?? "")
|
|
2254
|
+
.find((text) => text.includes("gsap.timeline"))!;
|
|
2255
|
+
const next =
|
|
2256
|
+
body.type === "shift-positions"
|
|
2257
|
+
? shiftPositionsInScript(old, String(body.targetSelector), Number(body.delta), root)
|
|
2258
|
+
: scalePositionsInScript(
|
|
2259
|
+
old,
|
|
2260
|
+
String(body.targetSelector),
|
|
2261
|
+
Number(body.oldStart),
|
|
2262
|
+
Number(body.oldDuration),
|
|
2263
|
+
Number(body.newStart),
|
|
2264
|
+
Number(body.newDuration),
|
|
2265
|
+
root,
|
|
2266
|
+
);
|
|
2267
|
+
const after = before.replace(old, next);
|
|
2268
|
+
files[path] = after;
|
|
2269
|
+
return {
|
|
2270
|
+
ok: true,
|
|
2271
|
+
mutated: after !== before,
|
|
2272
|
+
changed: after !== before,
|
|
2273
|
+
scriptText: next,
|
|
2274
|
+
before,
|
|
2275
|
+
after,
|
|
2276
|
+
};
|
|
2277
|
+
};
|
|
2278
|
+
const fetchMock = vi.fn(
|
|
2279
|
+
async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
2280
|
+
const url = requestUrl(input);
|
|
2281
|
+
if (url.includes("/gsap-mutation-capabilities"))
|
|
2282
|
+
return jsonResponse({ atomicOwnershipPairs: true });
|
|
2283
|
+
if (url.includes("/gsap-mutations/")) {
|
|
2284
|
+
return jsonResponse(
|
|
2285
|
+
applyServerMutation(pathAfter(url, "/gsap-mutations/"), JSON.parse(String(init?.body))),
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
if (url.includes("/files/"))
|
|
2289
|
+
return jsonResponse({ content: files[pathAfter(url, "/files/")] });
|
|
2290
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
2291
|
+
},
|
|
2292
|
+
);
|
|
2293
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2294
|
+
const writeProjectFile = vi.fn(async (path: string, content: string) => {
|
|
2295
|
+
files[path] = content;
|
|
2296
|
+
});
|
|
2297
|
+
return { files, fetchMock, writeProjectFile };
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
async function setupScene(withSdk: boolean, source = SCENE_SOURCE) {
|
|
2301
|
+
const project = stubProjectFiles({ [SCENE_PATH]: source });
|
|
2302
|
+
const iframe = createPreviewIframe([
|
|
2303
|
+
{ id: "scene", track: 0 },
|
|
2304
|
+
{ id: "side", track: 1 },
|
|
2305
|
+
]);
|
|
2306
|
+
const scene = timelineElement({
|
|
2307
|
+
id: "scene",
|
|
2308
|
+
track: 0,
|
|
2309
|
+
zIndex: 0,
|
|
2310
|
+
start: 1,
|
|
2311
|
+
duration: 4,
|
|
2312
|
+
sourceFile: SCENE_PATH,
|
|
2313
|
+
});
|
|
2314
|
+
const side = timelineElement({
|
|
2315
|
+
id: "side",
|
|
2316
|
+
track: 1,
|
|
2317
|
+
zIndex: 0,
|
|
2318
|
+
start: 0,
|
|
2319
|
+
duration: 8,
|
|
2320
|
+
sourceFile: SCENE_PATH,
|
|
2321
|
+
});
|
|
2322
|
+
usePlayerStore.getState().setDuration(10);
|
|
2323
|
+
const hook = renderTimelineEditingHook({
|
|
2324
|
+
timelineElements: [scene, side],
|
|
2325
|
+
iframe,
|
|
2326
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
2327
|
+
projectId: "p1",
|
|
2328
|
+
activeCompPath: SCENE_PATH,
|
|
2329
|
+
writeProjectFile: project.writeProjectFile,
|
|
2330
|
+
recordEdit: vi.fn(async () => {}),
|
|
2331
|
+
sdkSession: withSdk ? await openComposition(source) : undefined,
|
|
2332
|
+
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
|
|
2333
|
+
});
|
|
2334
|
+
const tweens = () =>
|
|
2335
|
+
project.files[SCENE_PATH]!.split("\n").filter((line) => line.startsWith("tl."));
|
|
2336
|
+
return { ...project, hook, scene, side, tweens };
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
it("a move then a stretch through the SDK sync once in a template file with a config script first", async () => {
|
|
2340
|
+
const withConfig = `<template id="scene-template">\n${SCENE_SOURCE.replace(
|
|
2341
|
+
"<script>",
|
|
2342
|
+
"<script>gsap.config({ nullTargetWarn: false });</script>\n<script>",
|
|
2343
|
+
)}\n</template>`;
|
|
2344
|
+
const h = await setupScene(true, withConfig);
|
|
2345
|
+
await act(async () => {
|
|
2346
|
+
await h.hook.move(h.scene, { start: 3, track: h.scene.track });
|
|
2347
|
+
await flushAsyncWork();
|
|
2348
|
+
});
|
|
2349
|
+
const moved = { ...h.scene, start: 3 };
|
|
2350
|
+
await act(async () => {
|
|
2351
|
+
await h.hook.resize(moved, { start: 3, duration: 6, playbackStart: undefined });
|
|
2352
|
+
await flushAsyncWork();
|
|
2353
|
+
});
|
|
2354
|
+
expect(h.tweens()).toEqual([
|
|
2355
|
+
`tl.to("#scene", { x: 1, duration: 1.5 }, 3);`,
|
|
2356
|
+
`tl.from("#scene h1", { y: 20, duration: 1.5 }, 3.75);`,
|
|
2357
|
+
`tl.to("#side", { x: 5, duration: 1 }, 2);`,
|
|
2358
|
+
]);
|
|
2359
|
+
h.hook.unmount();
|
|
2360
|
+
});
|
|
2361
|
+
|
|
2362
|
+
for (const withSdk of [true, false]) {
|
|
2363
|
+
const via = withSdk ? "through the SDK" : "through the server (no SDK session)";
|
|
2364
|
+
|
|
2365
|
+
it(`a move ${via} lands each tween once`, async () => {
|
|
2366
|
+
const h = await setupScene(withSdk);
|
|
2367
|
+
await act(async () => {
|
|
2368
|
+
await h.hook.move(h.scene, { start: 3, track: h.scene.track });
|
|
2369
|
+
await flushAsyncWork();
|
|
2370
|
+
});
|
|
2371
|
+
expect(h.tweens()).toEqual([
|
|
2372
|
+
`tl.to("#scene", { x: 1, duration: 1 }, 3);`,
|
|
2373
|
+
`tl.from("#scene h1", { y: 20, duration: 1 }, 3.5);`,
|
|
2374
|
+
`tl.to("#side", { x: 5, duration: 1 }, 2);`,
|
|
2375
|
+
]);
|
|
2376
|
+
h.hook.unmount();
|
|
2377
|
+
});
|
|
2378
|
+
|
|
2379
|
+
it(`a resize ${via} scales each tween once`, async () => {
|
|
2380
|
+
const h = await setupScene(withSdk);
|
|
2381
|
+
await act(async () => {
|
|
2382
|
+
await h.hook.resize(h.scene, { start: 1, duration: 8, playbackStart: undefined });
|
|
2383
|
+
await flushAsyncWork();
|
|
2384
|
+
});
|
|
2385
|
+
expect(h.tweens()).toEqual([
|
|
2386
|
+
`tl.to("#scene", { x: 1, duration: 2 }, 1);`,
|
|
2387
|
+
`tl.from("#scene h1", { y: 20, duration: 2 }, 2);`,
|
|
2388
|
+
`tl.to("#side", { x: 5, duration: 1 }, 2);`,
|
|
2389
|
+
]);
|
|
2390
|
+
h.hook.unmount();
|
|
2391
|
+
});
|
|
2392
|
+
|
|
2393
|
+
it(`a group move ${via} lands each tween once`, async () => {
|
|
2394
|
+
const h = await setupScene(withSdk);
|
|
2395
|
+
await act(async () => {
|
|
2396
|
+
await h.hook.groupMove([
|
|
2397
|
+
{ element: h.scene, start: 3 },
|
|
2398
|
+
{ element: h.side, start: 1 },
|
|
2399
|
+
]);
|
|
2400
|
+
await flushAsyncWork();
|
|
2401
|
+
});
|
|
2402
|
+
expect(h.tweens()).toEqual([
|
|
2403
|
+
`tl.to("#scene", { x: 1, duration: 1 }, 3);`,
|
|
2404
|
+
`tl.from("#scene h1", { y: 20, duration: 1 }, 3.5);`,
|
|
2405
|
+
`tl.to("#side", { x: 5, duration: 1 }, 3);`,
|
|
2406
|
+
]);
|
|
2407
|
+
h.hook.unmount();
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
});
|