@hyperframes/studio 0.7.75 → 0.7.77
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-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
- package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
- package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
- package/dist/assets/index-Bp4jAYZG.js +428 -0
- package/dist/assets/index-CBAfprvx.css +1 -0
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +18040 -16293
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-DzDajONI.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- package/src/utils/keyframeSelection.ts +0 -29
|
@@ -10,6 +10,17 @@ import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
|
|
|
10
10
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
11
11
|
import { useTimelineEditing } from "./useTimelineEditing";
|
|
12
12
|
|
|
13
|
+
vi.mock("../components/editor/manualEditingAvailability", async (importOriginal) => {
|
|
14
|
+
const actual =
|
|
15
|
+
await importOriginal<typeof import("../components/editor/manualEditingAvailability")>();
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
STUDIO_SDK_CUTOVER_ENABLED: true,
|
|
19
|
+
STUDIO_SDK_CUTOVER_FAMILIES: new Set(["timing"]),
|
|
20
|
+
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
13
24
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
25
|
|
|
15
26
|
type ZIndexEntry = {
|
|
@@ -108,7 +119,9 @@ function renderTimelineEditingHook(input: {
|
|
|
108
119
|
}) => Promise<void>;
|
|
109
120
|
reloadPreview?: () => void;
|
|
110
121
|
sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
|
|
122
|
+
publishSdkSession?: NonNullable<Parameters<typeof useTimelineEditing>[0]["publishSdkSession"]>;
|
|
111
123
|
forceReloadSdkSession?: () => void;
|
|
124
|
+
invalidateGsapCache?: () => void;
|
|
112
125
|
showToast?: (message: string, kind?: string) => void;
|
|
113
126
|
}): {
|
|
114
127
|
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
|
@@ -140,7 +153,9 @@ function renderTimelineEditingHook(input: {
|
|
|
140
153
|
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
141
154
|
uploadProjectFiles: vi.fn(),
|
|
142
155
|
sdkSession: input.sdkSession,
|
|
156
|
+
publishSdkSession: input.publishSdkSession,
|
|
143
157
|
forceReloadSdkSession: input.forceReloadSdkSession,
|
|
158
|
+
invalidateGsapCache: input.invalidateGsapCache,
|
|
144
159
|
handleDomZIndexReorderCommitRef: commitRef,
|
|
145
160
|
});
|
|
146
161
|
move = hook.handleTimelineElementMove;
|
|
@@ -163,6 +178,9 @@ function renderTimelineEditingHook(input: {
|
|
|
163
178
|
type TimelineRecordEdit = NonNullable<
|
|
164
179
|
Parameters<typeof renderTimelineEditingHook>[0]["recordEdit"]
|
|
165
180
|
>;
|
|
181
|
+
type TimelinePublishSdkSession = NonNullable<
|
|
182
|
+
Parameters<typeof renderTimelineEditingHook>[0]["publishSdkSession"]
|
|
183
|
+
>;
|
|
166
184
|
|
|
167
185
|
function renderTimelineEditingHookWithLifecycle(input: {
|
|
168
186
|
timelineElements: TimelineElement[];
|
|
@@ -227,28 +245,41 @@ async function flushAsyncWork(): Promise<void> {
|
|
|
227
245
|
* with `gsapBody`. Returns the mock for call inspection.
|
|
228
246
|
*/
|
|
229
247
|
function stubProjectFetch(files: string | Record<string, string>, gsapBody?: unknown) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
248
|
+
const pathAfter = (url: string, marker: string) =>
|
|
249
|
+
decodeURIComponent(url.split(marker)[1] ?? "index.html");
|
|
250
|
+
const fileContent = (path: string) => (typeof files === "string" ? files : files[path]);
|
|
251
|
+
// One handler per route, so the mock itself stays a lookup: the request
|
|
252
|
+
// sequence callers assert on is still readable top to bottom.
|
|
253
|
+
const routes: Array<[marker: string, respond: (url: string) => Response]> = [
|
|
254
|
+
[
|
|
255
|
+
"/api/projects/p1/gsap-mutation-capabilities",
|
|
256
|
+
() => jsonResponse({ atomicOwnershipPairs: true }),
|
|
257
|
+
],
|
|
258
|
+
[
|
|
259
|
+
"/api/projects/p1/files/",
|
|
260
|
+
(url) => jsonResponse({ content: fileContent(pathAfter(url, "/files/")) }),
|
|
261
|
+
],
|
|
262
|
+
[
|
|
263
|
+
"/api/projects/p1/gsap-mutations/",
|
|
264
|
+
(url) => {
|
|
265
|
+
const content = fileContent(pathAfter(url, "/gsap-mutations/")) ?? "";
|
|
266
|
+
return jsonResponse(
|
|
267
|
+
gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
|
|
268
|
+
);
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
];
|
|
272
|
+
const fetchMock = vi.fn(
|
|
273
|
+
async (
|
|
274
|
+
input: Parameters<typeof fetch>[0],
|
|
275
|
+
_init?: Parameters<typeof fetch>[1],
|
|
276
|
+
): Promise<Response> => {
|
|
277
|
+
const url = requestUrl(input);
|
|
278
|
+
const route = routes.find(([marker]) => url.includes(marker));
|
|
279
|
+
if (!route) throw new Error(`Unexpected fetch: ${url}`);
|
|
280
|
+
return route[1](url);
|
|
281
|
+
},
|
|
282
|
+
);
|
|
252
283
|
vi.stubGlobal("fetch", fetchMock);
|
|
253
284
|
return fetchMock;
|
|
254
285
|
}
|
|
@@ -285,6 +316,39 @@ function setupSingleClipHarness(options?: {
|
|
|
285
316
|
return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
|
|
286
317
|
}
|
|
287
318
|
|
|
319
|
+
const SDK_KEYFRAMED_SOURCE = [
|
|
320
|
+
`<div data-hf-id="hf-stage" data-hf-root data-composition-id="main" data-duration="10">`,
|
|
321
|
+
` <div id="clip" data-hf-id="hf-clip" data-start="1" data-duration="2"></div>`,
|
|
322
|
+
`</div>`,
|
|
323
|
+
`<script>`,
|
|
324
|
+
`const tl = gsap.timeline({ paused: true });`,
|
|
325
|
+
`tl.to("#clip", { keyframes: [{ x: 0 }, { x: 100 }], duration: 2 }, 1);`,
|
|
326
|
+
`window.__timelines = [tl];`,
|
|
327
|
+
`</script>`,
|
|
328
|
+
].join("\n");
|
|
329
|
+
|
|
330
|
+
async function setupSdkKeyframedClipHarness() {
|
|
331
|
+
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
332
|
+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 1 });
|
|
333
|
+
const sdkSession = await openComposition(SDK_KEYFRAMED_SOURCE);
|
|
334
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
335
|
+
const invalidateGsapCache = vi.fn();
|
|
336
|
+
const fetchMock = stubProjectFetch(SDK_KEYFRAMED_SOURCE);
|
|
337
|
+
usePlayerStore.getState().setDuration(10);
|
|
338
|
+
const hook = renderTimelineEditingHook({
|
|
339
|
+
timelineElements: [clip],
|
|
340
|
+
iframe,
|
|
341
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
342
|
+
projectId: "p1",
|
|
343
|
+
writeProjectFile,
|
|
344
|
+
recordEdit: vi.fn(async () => {}),
|
|
345
|
+
sdkSession,
|
|
346
|
+
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
|
|
347
|
+
invalidateGsapCache,
|
|
348
|
+
});
|
|
349
|
+
return { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile };
|
|
350
|
+
}
|
|
351
|
+
|
|
288
352
|
/** Assert a lane write landed in both the live iframe DOM and the persisted file. */
|
|
289
353
|
function expectLanePersisted(
|
|
290
354
|
iframe: HTMLIFrameElement,
|
|
@@ -710,6 +774,58 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
710
774
|
h.unmount();
|
|
711
775
|
});
|
|
712
776
|
|
|
777
|
+
it("shifts authored GSAP positions after an SDK-backed clip move commits", async () => {
|
|
778
|
+
const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
|
|
779
|
+
await setupSdkKeyframedClipHarness();
|
|
780
|
+
|
|
781
|
+
await act(async () => {
|
|
782
|
+
await hook.move(clip, { start: 2.25, track: clip.track });
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2.25"');
|
|
786
|
+
const mutationCall = fetchMock.mock.calls.find((call) =>
|
|
787
|
+
requestUrl(call[0]).includes("/gsap-mutations/"),
|
|
788
|
+
);
|
|
789
|
+
expect(mutationCall).toBeDefined();
|
|
790
|
+
const init = mutationCall?.[1] as RequestInit | undefined;
|
|
791
|
+
expect(JSON.parse(String(init?.body))).toEqual({
|
|
792
|
+
type: "shift-positions",
|
|
793
|
+
targetSelector: "#clip",
|
|
794
|
+
delta: 1.25,
|
|
795
|
+
});
|
|
796
|
+
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
|
|
797
|
+
|
|
798
|
+
hook.unmount();
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it("scales authored GSAP positions after an SDK-backed clip resize commits", async () => {
|
|
802
|
+
const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
|
|
803
|
+
await setupSdkKeyframedClipHarness();
|
|
804
|
+
|
|
805
|
+
await act(async () => {
|
|
806
|
+
await hook.resize(clip, { start: 2, duration: 4, playbackStart: undefined });
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2"');
|
|
810
|
+
expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-duration="4"');
|
|
811
|
+
const mutationCall = fetchMock.mock.calls.find((call) =>
|
|
812
|
+
requestUrl(call[0]).includes("/gsap-mutations/"),
|
|
813
|
+
);
|
|
814
|
+
expect(mutationCall).toBeDefined();
|
|
815
|
+
const init = mutationCall?.[1] as RequestInit | undefined;
|
|
816
|
+
expect(JSON.parse(String(init?.body))).toEqual({
|
|
817
|
+
type: "scale-positions",
|
|
818
|
+
targetSelector: "#clip",
|
|
819
|
+
oldStart: 1,
|
|
820
|
+
oldDuration: 2,
|
|
821
|
+
newStart: 2,
|
|
822
|
+
newDuration: 4,
|
|
823
|
+
});
|
|
824
|
+
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
|
|
825
|
+
|
|
826
|
+
hook.unmount();
|
|
827
|
+
});
|
|
828
|
+
|
|
713
829
|
it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
|
|
714
830
|
// Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
|
|
715
831
|
// pure lane change routed through onMoveElement (no onMoveElements wired)
|
|
@@ -821,6 +937,55 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|
|
821
937
|
unmount();
|
|
822
938
|
});
|
|
823
939
|
|
|
940
|
+
it("shifts every keyed clip and invalidates the cache after an SDK-backed group move", async () => {
|
|
941
|
+
const source = [
|
|
942
|
+
`<div data-hf-id="hf-stage" data-hf-root data-duration="10">`,
|
|
943
|
+
` <div id="a" data-hf-id="hf-a" data-start="0" data-duration="1"></div>`,
|
|
944
|
+
` <div id="b" data-hf-id="hf-b" data-start="1" data-duration="1"></div>`,
|
|
945
|
+
`</div>`,
|
|
946
|
+
`<script>`,
|
|
947
|
+
`const tl = gsap.timeline({ paused: true });`,
|
|
948
|
+
`tl.to("#a", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 0);`,
|
|
949
|
+
`tl.to("#b", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 1);`,
|
|
950
|
+
`window.__timelines = [tl];`,
|
|
951
|
+
`</script>`,
|
|
952
|
+
].join("\n");
|
|
953
|
+
const { iframe, a, b } = makeTwoClipPair();
|
|
954
|
+
const sdkSession = await openComposition(source);
|
|
955
|
+
const fetchMock = stubProjectFetch(source);
|
|
956
|
+
const invalidateGsapCache = vi.fn();
|
|
957
|
+
usePlayerStore.getState().setDuration(10);
|
|
958
|
+
const hook = renderTimelineEditingHook({
|
|
959
|
+
timelineElements: [a, b],
|
|
960
|
+
iframe,
|
|
961
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
962
|
+
projectId: "p1",
|
|
963
|
+
writeProjectFile: vi.fn<(...args: unknown[]) => Promise<void>>(async () => {}),
|
|
964
|
+
recordEdit: vi.fn(async () => {}),
|
|
965
|
+
sdkSession,
|
|
966
|
+
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
|
|
967
|
+
invalidateGsapCache,
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
await act(async () => {
|
|
971
|
+
await hook.groupMove([
|
|
972
|
+
{ element: a, start: 1 },
|
|
973
|
+
{ element: b, start: 2 },
|
|
974
|
+
]);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
const mutations = fetchMock.mock.calls
|
|
978
|
+
.filter((call) => requestUrl(call[0]).includes("/gsap-mutations/"))
|
|
979
|
+
.map((call) => JSON.parse(String((call[1] as RequestInit | undefined)?.body)));
|
|
980
|
+
expect(mutations).toEqual([
|
|
981
|
+
{ type: "shift-positions", targetSelector: "#a", delta: 1 },
|
|
982
|
+
{ type: "shift-positions", targetSelector: "#b", delta: 1 },
|
|
983
|
+
]);
|
|
984
|
+
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
|
|
985
|
+
|
|
986
|
+
hook.unmount();
|
|
987
|
+
});
|
|
988
|
+
|
|
824
989
|
it("partitions a group move by source file while keeping one undo entry", async () => {
|
|
825
990
|
const files: Record<string, string> = {
|
|
826
991
|
"index.html": '<div id="a" data-start="0" data-duration="1"></div>',
|
|
@@ -58,6 +58,7 @@ export function useTimelineEditing({
|
|
|
58
58
|
sdkSession,
|
|
59
59
|
publishSdkSession,
|
|
60
60
|
forceReloadSdkSession,
|
|
61
|
+
invalidateGsapCache,
|
|
61
62
|
handleDomZIndexReorderCommitRef,
|
|
62
63
|
}: UseTimelineEditingOptions) {
|
|
63
64
|
const projectIdRef = useRef(projectId);
|
|
@@ -118,6 +119,7 @@ export function useTimelineEditing({
|
|
|
118
119
|
domEditSaveTimestampRef,
|
|
119
120
|
editQueueRef,
|
|
120
121
|
forceReloadSdkSession,
|
|
122
|
+
invalidateGsapCache,
|
|
121
123
|
isRecordingRef,
|
|
122
124
|
pendingTimelineEditPathRef,
|
|
123
125
|
previewIframeRef,
|
|
@@ -184,21 +186,24 @@ export function useTimelineEditing({
|
|
|
184
186
|
);
|
|
185
187
|
};
|
|
186
188
|
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
|
|
189
|
+
const finishMoveGsapSync = () =>
|
|
190
|
+
// Every timing writer converges the same GSAP positions after its
|
|
191
|
+
// durable clip-start commit. The SDK owns the attribute write; this
|
|
192
|
+
// sync owns only the dependent animation rewrite and preview refresh.
|
|
193
|
+
finishClipTimingFallback({
|
|
194
|
+
iframe: previewIframeRef.current,
|
|
195
|
+
reloadPreview,
|
|
196
|
+
projectId: projectIdRef.current,
|
|
197
|
+
targetPath,
|
|
198
|
+
domId: element.domId,
|
|
199
|
+
label: "Move timeline clip",
|
|
200
|
+
coalesceKey,
|
|
201
|
+
recordEdit,
|
|
202
|
+
edit: { kind: "shift", delta: updates.start - element.start },
|
|
203
|
+
}).finally(() => invalidateGsapCache?.());
|
|
187
204
|
const moveFallback = () =>
|
|
188
|
-
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(
|
|
189
|
-
|
|
190
|
-
// DOM + store, so swapping the script avoids the all-clips flash; falls back to reloadPreview().
|
|
191
|
-
finishClipTimingFallback({
|
|
192
|
-
iframe: previewIframeRef.current,
|
|
193
|
-
reloadPreview,
|
|
194
|
-
projectId: projectIdRef.current,
|
|
195
|
-
targetPath,
|
|
196
|
-
domId: element.domId,
|
|
197
|
-
label: "Move timeline clip",
|
|
198
|
-
coalesceKey,
|
|
199
|
-
recordEdit,
|
|
200
|
-
edit: { kind: "shift", delta: updates.start - element.start },
|
|
201
|
-
}),
|
|
205
|
+
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(
|
|
206
|
+
finishMoveGsapSync,
|
|
202
207
|
);
|
|
203
208
|
return reorderDone
|
|
204
209
|
.then(() => {
|
|
@@ -221,9 +226,10 @@ export function useTimelineEditing({
|
|
|
221
226
|
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
|
222
227
|
publishSession: publishSdkSession,
|
|
223
228
|
},
|
|
224
|
-
{ label: "Move timeline clip", coalesceKey },
|
|
229
|
+
{ label: "Move timeline clip", coalesceKey, skipRefresh: true },
|
|
225
230
|
).then((result) => {
|
|
226
231
|
if (!cutoverCommittedOrThrow(result)) return moveFallback();
|
|
232
|
+
return finishMoveGsapSync();
|
|
227
233
|
});
|
|
228
234
|
}
|
|
229
235
|
return moveFallback();
|
|
@@ -250,6 +256,7 @@ export function useTimelineEditing({
|
|
|
250
256
|
timelineElements,
|
|
251
257
|
handleDomZIndexReorderCommitRef,
|
|
252
258
|
showToast,
|
|
259
|
+
invalidateGsapCache,
|
|
253
260
|
],
|
|
254
261
|
);
|
|
255
262
|
|
|
@@ -287,23 +294,25 @@ export function useTimelineEditing({
|
|
|
287
294
|
// script (timing-only resize) — same no-flash path as move; full reload is
|
|
288
295
|
// the fallback.
|
|
289
296
|
const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
|
|
297
|
+
const finishResizeGsapSync = () =>
|
|
298
|
+
finishClipTimingFallback({
|
|
299
|
+
iframe: previewIframeRef.current,
|
|
300
|
+
reloadPreview,
|
|
301
|
+
projectId: projectIdRef.current,
|
|
302
|
+
targetPath,
|
|
303
|
+
domId: element.domId,
|
|
304
|
+
label: "Resize timeline clip",
|
|
305
|
+
coalesceKey,
|
|
306
|
+
recordEdit,
|
|
307
|
+
edit: {
|
|
308
|
+
kind: "scale",
|
|
309
|
+
from: { start: element.start, duration: element.duration },
|
|
310
|
+
to: { start: updates.start, duration: updates.duration },
|
|
311
|
+
},
|
|
312
|
+
}).finally(() => invalidateGsapCache?.());
|
|
290
313
|
const resizeFallback = () =>
|
|
291
|
-
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(
|
|
292
|
-
|
|
293
|
-
iframe: previewIframeRef.current,
|
|
294
|
-
reloadPreview,
|
|
295
|
-
projectId: projectIdRef.current,
|
|
296
|
-
targetPath,
|
|
297
|
-
domId: element.domId,
|
|
298
|
-
label: "Resize timeline clip",
|
|
299
|
-
coalesceKey,
|
|
300
|
-
recordEdit,
|
|
301
|
-
edit: {
|
|
302
|
-
kind: "scale",
|
|
303
|
-
from: { start: element.start, duration: element.duration },
|
|
304
|
-
to: { start: updates.start, duration: updates.duration },
|
|
305
|
-
},
|
|
306
|
-
}),
|
|
314
|
+
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(
|
|
315
|
+
finishResizeGsapSync,
|
|
307
316
|
);
|
|
308
317
|
const persistDone =
|
|
309
318
|
sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
|
|
@@ -323,9 +332,10 @@ export function useTimelineEditing({
|
|
|
323
332
|
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
|
324
333
|
publishSession: publishSdkSession,
|
|
325
334
|
},
|
|
326
|
-
{ label: "Resize timeline clip", coalesceKey },
|
|
335
|
+
{ label: "Resize timeline clip", coalesceKey, skipRefresh: true },
|
|
327
336
|
).then((result) => {
|
|
328
337
|
if (!cutoverCommittedOrThrow(result)) return resizeFallback();
|
|
338
|
+
return finishResizeGsapSync();
|
|
329
339
|
})
|
|
330
340
|
: resizeFallback();
|
|
331
341
|
return persistDone.catch((error) => {
|
|
@@ -346,6 +356,7 @@ export function useTimelineEditing({
|
|
|
346
356
|
reloadPreview,
|
|
347
357
|
domEditSaveTimestampRef,
|
|
348
358
|
showToast,
|
|
359
|
+
invalidateGsapCache,
|
|
349
360
|
],
|
|
350
361
|
);
|
|
351
362
|
|
|
@@ -46,6 +46,8 @@ export interface UseTimelineEditingOptions {
|
|
|
46
46
|
publishSdkSession?: PublishSdkSession;
|
|
47
47
|
/** Resync the SDK session after a server-authoritative timeline write. */
|
|
48
48
|
forceReloadSdkSession?: () => void;
|
|
49
|
+
/** Reparse authored animations after a timing rewrite changes their positions. */
|
|
50
|
+
invalidateGsapCache?: () => void;
|
|
49
51
|
handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -54,6 +54,7 @@ interface UseTimelineGroupEditingOptions {
|
|
|
54
54
|
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
55
55
|
editQueueRef: MutableRefObject<Promise<unknown>>;
|
|
56
56
|
forceReloadSdkSession?: () => void;
|
|
57
|
+
invalidateGsapCache?: () => void;
|
|
57
58
|
isRecordingRef?: RefObject<boolean>;
|
|
58
59
|
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
|
59
60
|
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
|
@@ -110,6 +111,7 @@ export function useTimelineGroupEditing({
|
|
|
110
111
|
domEditSaveTimestampRef,
|
|
111
112
|
editQueueRef,
|
|
112
113
|
forceReloadSdkSession,
|
|
114
|
+
invalidateGsapCache,
|
|
113
115
|
isRecordingRef,
|
|
114
116
|
pendingTimelineEditPathRef,
|
|
115
117
|
previewIframeRef,
|
|
@@ -212,7 +214,12 @@ export function useTimelineGroupEditing({
|
|
|
212
214
|
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
|
213
215
|
publishSession: publishSdkSession,
|
|
214
216
|
},
|
|
215
|
-
{
|
|
217
|
+
{
|
|
218
|
+
label: input.label,
|
|
219
|
+
coalesceKey: input.coalesceKey,
|
|
220
|
+
coalesceMs: input.coalesceMs,
|
|
221
|
+
skipRefresh: true,
|
|
222
|
+
},
|
|
216
223
|
);
|
|
217
224
|
return cutoverCommittedOrThrow(result);
|
|
218
225
|
},
|
|
@@ -282,47 +289,55 @@ export function useTimelineGroupEditing({
|
|
|
282
289
|
coalesceKey,
|
|
283
290
|
coalesceMs,
|
|
284
291
|
});
|
|
285
|
-
if (handledBySdk)
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
292
|
+
if (!handledBySdk) {
|
|
293
|
+
await persistServerBatch(
|
|
294
|
+
projectId,
|
|
295
|
+
"Move timeline clips",
|
|
296
|
+
changes.map((change) => ({
|
|
297
|
+
element: change.element,
|
|
298
|
+
buildPatches: (original, target) =>
|
|
299
|
+
buildTimelineMoveTimingPatch(
|
|
300
|
+
original,
|
|
301
|
+
target,
|
|
302
|
+
change.start,
|
|
303
|
+
change.element.duration,
|
|
304
|
+
change.track,
|
|
305
|
+
),
|
|
306
|
+
})),
|
|
307
|
+
coalesceKey,
|
|
308
|
+
coalesceMs,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
304
311
|
// Track-only: no timing delta → no GSAP positions to shift and no
|
|
305
312
|
// reload (see the trackOnly doc above). Mixed batches (any start
|
|
306
313
|
// change) keep the full fallback below.
|
|
307
314
|
if (trackOnly) return;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
315
|
+
// The timing persist above already committed to disk, so the cached
|
|
316
|
+
// GSAP read is stale whether or not the position rewrite succeeded —
|
|
317
|
+
// invalidate on the error path too (matches the single-element path's
|
|
318
|
+
// `.finally`), or a failed rewrite leaves the editor reading old tweens.
|
|
319
|
+
try {
|
|
320
|
+
await finishGroupTimingGsapFallback({
|
|
321
|
+
projectId,
|
|
322
|
+
iframe: previewIframeRef.current,
|
|
323
|
+
reloadPreview,
|
|
324
|
+
label: "Move timeline clips",
|
|
325
|
+
errorLabel: "Failed to shift GSAP positions",
|
|
326
|
+
coalesceKey,
|
|
327
|
+
recordEdit,
|
|
328
|
+
activeCompPath,
|
|
329
|
+
changes,
|
|
330
|
+
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
|
331
|
+
mutateChange: (change, changePath) => {
|
|
332
|
+
const delta = change.start - change.element.start;
|
|
333
|
+
const domId = change.element.domId;
|
|
334
|
+
if (delta === 0 || !domId) return null;
|
|
335
|
+
return shiftGsapPositions(projectId, changePath, domId, delta);
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
} finally {
|
|
339
|
+
invalidateGsapCache?.();
|
|
340
|
+
}
|
|
326
341
|
}).catch((error) => {
|
|
327
342
|
// Failed persist: revert the optimistic duration readout + live root
|
|
328
343
|
// alongside the gesture owner's store rollback.
|
|
@@ -340,6 +355,7 @@ export function useTimelineGroupEditing({
|
|
|
340
355
|
reloadPreview,
|
|
341
356
|
trySdkBatchPersist,
|
|
342
357
|
showToast,
|
|
358
|
+
invalidateGsapCache,
|
|
343
359
|
],
|
|
344
360
|
);
|
|
345
361
|
|
|
@@ -384,50 +400,57 @@ export function useTimelineGroupEditing({
|
|
|
384
400
|
coalesceKey,
|
|
385
401
|
coalesceMs,
|
|
386
402
|
});
|
|
387
|
-
if (handledBySdk)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
domId
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
403
|
+
if (!handledBySdk) {
|
|
404
|
+
await persistServerBatch(
|
|
405
|
+
projectId,
|
|
406
|
+
"Resize timeline clips",
|
|
407
|
+
changes.map((change) => ({
|
|
408
|
+
element: change.element,
|
|
409
|
+
buildPatches: (original, target) =>
|
|
410
|
+
buildTimelineResizeTimingPatch(original, target, change.element, {
|
|
411
|
+
start: change.start,
|
|
412
|
+
duration: change.duration,
|
|
413
|
+
playbackStart: change.playbackStart,
|
|
414
|
+
}),
|
|
415
|
+
})),
|
|
416
|
+
coalesceKey,
|
|
417
|
+
coalesceMs,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
// See the move path: the timing persist is already on disk, so the GSAP
|
|
421
|
+
// cache must be invalidated even when the position rewrite throws.
|
|
422
|
+
try {
|
|
423
|
+
await finishGroupTimingGsapFallback({
|
|
424
|
+
projectId,
|
|
425
|
+
iframe: previewIframeRef.current,
|
|
426
|
+
reloadPreview,
|
|
427
|
+
label: "Resize timeline clips",
|
|
428
|
+
errorLabel: "Failed to scale GSAP positions",
|
|
429
|
+
coalesceKey,
|
|
430
|
+
recordEdit,
|
|
431
|
+
activeCompPath,
|
|
432
|
+
changes,
|
|
433
|
+
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
|
434
|
+
mutateChange: (change, changePath) => {
|
|
435
|
+
const domId = change.element.domId;
|
|
436
|
+
const timingChanged =
|
|
437
|
+
change.start !== change.element.start ||
|
|
438
|
+
change.duration !== change.element.duration;
|
|
439
|
+
if (!timingChanged || !domId) return null;
|
|
440
|
+
return scaleGsapPositions(
|
|
441
|
+
projectId,
|
|
442
|
+
changePath,
|
|
443
|
+
domId,
|
|
444
|
+
change.element.start,
|
|
445
|
+
change.element.duration,
|
|
446
|
+
change.start,
|
|
447
|
+
change.duration,
|
|
448
|
+
);
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
} finally {
|
|
452
|
+
invalidateGsapCache?.();
|
|
453
|
+
}
|
|
431
454
|
}).catch((error) => {
|
|
432
455
|
// Failed persist: revert the optimistic duration readout + live root
|
|
433
456
|
// alongside the gesture owner's store rollback.
|
|
@@ -445,6 +468,7 @@ export function useTimelineGroupEditing({
|
|
|
445
468
|
reloadPreview,
|
|
446
469
|
trySdkBatchPersist,
|
|
447
470
|
showToast,
|
|
471
|
+
invalidateGsapCache,
|
|
448
472
|
],
|
|
449
473
|
);
|
|
450
474
|
|