@hyperframes/studio 0.8.98 → 0.8.100
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-fWt87gyh.js} +1 -1
- package/dist/assets/{index-B6vU39NM.js → index-D34Oy5DN.js} +1 -1
- package/dist/assets/{index-itc1XziP.js → index-D6TjRAvz.js} +171 -171
- package/dist/assets/{index-BxnoLgxG.js → index-DofdzdxJ.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +718 -438
- 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/PropertyPanel.tsx +3 -3
- 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/domEditOverlayStartGesture.test.ts +83 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
- 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 +84 -0
- package/src/components/editor/manualOffsetDrag.ts +50 -40
- package/src/components/editor/plainTranslate.test.ts +62 -0
- package/src/components/editor/plainTranslate.ts +85 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
- package/src/components/editor/useDomEditNudge.ts +1 -1
- package/src/hooks/domSelectionTestHarness.ts +8 -0
- package/src/hooks/draggedGsapPosition.ts +4 -2
- package/src/hooks/elementOffsetStager.ts +33 -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 +3 -18
- package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +69 -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 +18 -4
- package/src/hooks/useDomEditCommits.ts +3 -1
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
- package/src/hooks/useDomEditTextCommits.ts +35 -35
- package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
- package/src/hooks/useDomGeometryCommit.ts +1 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +116 -2
- package/src/hooks/useDomGeometryCommits.ts +5 -0
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +147 -23
- package/src/hooks/useGsapAwareEditing.ts +48 -34
- 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
|
@@ -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
|
+
});
|
|
@@ -19,6 +19,7 @@ import { playbackStartAttributeForElement } from "../player/lib/timelineElementH
|
|
|
19
19
|
import {
|
|
20
20
|
captureDurationRollback,
|
|
21
21
|
finishClipTimingFallback,
|
|
22
|
+
sdkTimingGsapSync,
|
|
22
23
|
readFileContent,
|
|
23
24
|
syncPreviewContentDuration,
|
|
24
25
|
} from "./timelineTimingSync";
|
|
@@ -229,10 +230,8 @@ export function useTimelineEditing({
|
|
|
229
230
|
);
|
|
230
231
|
};
|
|
231
232
|
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
|
|
232
|
-
const finishMoveGsapSync = () =>
|
|
233
|
-
//
|
|
234
|
-
// durable clip-start commit. The SDK owns the attribute write; this
|
|
235
|
-
// sync owns only the dependent animation rewrite and preview refresh.
|
|
233
|
+
const finishMoveGsapSync = (sdkGsap?: ReturnType<typeof sdkTimingGsapSync>) =>
|
|
234
|
+
// One GSAP sync per edit: the SDK commit's own (sdkGsap), else the server rewrite here.
|
|
236
235
|
finishClipTimingFallback({
|
|
237
236
|
iframe: previewIframeRef.current,
|
|
238
237
|
reloadPreview,
|
|
@@ -244,15 +243,15 @@ export function useTimelineEditing({
|
|
|
244
243
|
recordEdit,
|
|
245
244
|
writeProjectFile,
|
|
246
245
|
edit: { kind: "shift", delta: updates.start - element.start },
|
|
246
|
+
sdkGsap,
|
|
247
247
|
}).finally(() => invalidateGsapCache?.());
|
|
248
248
|
const moveFallback = () =>
|
|
249
|
-
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(
|
|
250
|
-
finishMoveGsapSync,
|
|
249
|
+
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
|
|
250
|
+
finishMoveGsapSync(),
|
|
251
251
|
);
|
|
252
252
|
return reorderDone
|
|
253
253
|
.then(() => {
|
|
254
|
-
//
|
|
255
|
-
// the fallback, whose patch builder writes data-track-index too.
|
|
254
|
+
// SDK setTiming writes start only; a lane change needs the fallback's track patch.
|
|
256
255
|
if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
|
|
257
256
|
return sdkTimingPersist(
|
|
258
257
|
element.hfId,
|
|
@@ -272,7 +271,7 @@ export function useTimelineEditing({
|
|
|
272
271
|
{ label: "Move timeline clip", coalesceKey, skipRefresh: true },
|
|
273
272
|
).then((result) => {
|
|
274
273
|
if (!cutoverCommittedOrThrow(result)) return moveFallback();
|
|
275
|
-
return finishMoveGsapSync();
|
|
274
|
+
return finishMoveGsapSync(sdkTimingGsapSync(result));
|
|
276
275
|
});
|
|
277
276
|
}
|
|
278
277
|
return moveFallback();
|
|
@@ -337,7 +336,7 @@ export function useTimelineEditing({
|
|
|
337
336
|
// script (timing-only resize) — same no-flash path as move; full reload is
|
|
338
337
|
// the fallback.
|
|
339
338
|
const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
|
|
340
|
-
const finishResizeGsapSync = () =>
|
|
339
|
+
const finishResizeGsapSync = (sdkGsap?: ReturnType<typeof sdkTimingGsapSync>) =>
|
|
341
340
|
finishClipTimingFallback({
|
|
342
341
|
iframe: previewIframeRef.current,
|
|
343
342
|
reloadPreview,
|
|
@@ -353,10 +352,11 @@ export function useTimelineEditing({
|
|
|
353
352
|
from: { start: toCompositionTime(element, element.start), duration: element.duration },
|
|
354
353
|
to: { start: toCompositionTime(element, updates.start), duration: updates.duration },
|
|
355
354
|
},
|
|
355
|
+
sdkGsap,
|
|
356
356
|
}).finally(() => invalidateGsapCache?.());
|
|
357
357
|
const resizeFallback = () =>
|
|
358
|
-
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(
|
|
359
|
-
finishResizeGsapSync,
|
|
358
|
+
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() =>
|
|
359
|
+
finishResizeGsapSync(),
|
|
360
360
|
);
|
|
361
361
|
const persistDone =
|
|
362
362
|
sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
|
|
@@ -378,7 +378,7 @@ export function useTimelineEditing({
|
|
|
378
378
|
{ label: "Resize timeline clip", coalesceKey, skipRefresh: true },
|
|
379
379
|
).then((result) => {
|
|
380
380
|
if (!cutoverCommittedOrThrow(result)) return resizeFallback();
|
|
381
|
-
return finishResizeGsapSync();
|
|
381
|
+
return finishResizeGsapSync(sdkTimingGsapSync(result));
|
|
382
382
|
})
|
|
383
383
|
: resizeFallback();
|
|
384
384
|
return persistDone.catch((error) => {
|
|
@@ -24,11 +24,13 @@ import { playbackStartAttributeForElement } from "../player/lib/timelineElementH
|
|
|
24
24
|
import {
|
|
25
25
|
captureDurationRollback,
|
|
26
26
|
finishGroupTimingGsapFallback,
|
|
27
|
+
sdkTimingGsapSync,
|
|
27
28
|
readFileContent,
|
|
28
29
|
scaleGsapPositions,
|
|
29
30
|
shiftGsapPositions,
|
|
30
31
|
syncPreviewContentDuration,
|
|
31
32
|
} from "./timelineTimingSync";
|
|
33
|
+
import type { GsapMutationStatus } from "./gsapMutationClient";
|
|
32
34
|
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
33
35
|
|
|
34
36
|
export interface TimelineGroupMoveChange {
|
|
@@ -186,8 +188,8 @@ export function useTimelineGroupEditing({
|
|
|
186
188
|
|
|
187
189
|
// Shared SDK fast path for group move/resize: eligible when nothing needs the
|
|
188
190
|
// server (no root-duration growth, one shared file, every change SDK-addressable
|
|
189
|
-
// and `eligible` per the caller's own gate). Returns
|
|
190
|
-
//
|
|
191
|
+
// and `eligible` per the caller's own gate). Returns the SDK's GSAP sync (null: the
|
|
192
|
+
// server syncs) when it handled the edit; null → caller falls through to the server.
|
|
191
193
|
const trySdkBatchPersist = useCallback(
|
|
192
194
|
async (input: {
|
|
193
195
|
changes: readonly { element: TimelineElement }[];
|
|
@@ -200,14 +202,14 @@ export function useTimelineGroupEditing({
|
|
|
200
202
|
label: string;
|
|
201
203
|
coalesceKey: string;
|
|
202
204
|
coalesceMs?: number;
|
|
203
|
-
}): Promise<
|
|
205
|
+
}): Promise<{ sdkGsap: GsapMutationStatus | null } | null> => {
|
|
204
206
|
const sharedPath = allChangesSharePath(input.changes, activeCompPath);
|
|
205
207
|
const canUseSdk =
|
|
206
208
|
!input.needsExtension &&
|
|
207
209
|
sharedPath !== null &&
|
|
208
210
|
input.eligible &&
|
|
209
211
|
input.sdkChanges.every((change) => change !== null);
|
|
210
|
-
if (!canUseSdk) return
|
|
212
|
+
if (!canUseSdk) return null;
|
|
211
213
|
const result = await sdkTimingBatchPersist(
|
|
212
214
|
input.sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
|
|
213
215
|
sharedPath,
|
|
@@ -227,7 +229,7 @@ export function useTimelineGroupEditing({
|
|
|
227
229
|
skipRefresh: true,
|
|
228
230
|
},
|
|
229
231
|
);
|
|
230
|
-
return cutoverCommittedOrThrow(result);
|
|
232
|
+
return cutoverCommittedOrThrow(result) ? { sdkGsap: sdkTimingGsapSync(result) } : null;
|
|
231
233
|
},
|
|
232
234
|
[
|
|
233
235
|
activeCompPath,
|
|
@@ -289,7 +291,7 @@ export function useTimelineGroupEditing({
|
|
|
289
291
|
const label = options?.label ?? "Move timeline clips";
|
|
290
292
|
return enqueueGroupOperation(label, async (projectId) => {
|
|
291
293
|
await options?.beforeTiming;
|
|
292
|
-
const
|
|
294
|
+
const sdk = await trySdkBatchPersist({
|
|
293
295
|
changes,
|
|
294
296
|
sdkChanges: toSdkTimingChanges(changes, (change) => ({
|
|
295
297
|
start: toAuthoredStart(change.element, change.start),
|
|
@@ -300,7 +302,7 @@ export function useTimelineGroupEditing({
|
|
|
300
302
|
coalesceKey,
|
|
301
303
|
coalesceMs,
|
|
302
304
|
});
|
|
303
|
-
if (!
|
|
305
|
+
if (!sdk) {
|
|
304
306
|
await persistServerBatch(
|
|
305
307
|
projectId,
|
|
306
308
|
label,
|
|
@@ -339,6 +341,7 @@ export function useTimelineGroupEditing({
|
|
|
339
341
|
writeProjectFile,
|
|
340
342
|
activeCompPath,
|
|
341
343
|
changes,
|
|
344
|
+
sdkGsap: sdk?.sdkGsap,
|
|
342
345
|
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
|
343
346
|
mutateChange: (change, changePath) => {
|
|
344
347
|
const delta = change.start - change.element.start;
|
|
@@ -408,7 +411,7 @@ export function useTimelineGroupEditing({
|
|
|
408
411
|
const coalesceMs = options?.coalesceMs;
|
|
409
412
|
return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
|
|
410
413
|
await options?.beforeTiming;
|
|
411
|
-
const
|
|
414
|
+
const sdk = await trySdkBatchPersist({
|
|
412
415
|
changes,
|
|
413
416
|
sdkChanges: toSdkTimingChanges(changes, (change) => ({
|
|
414
417
|
start: toAuthoredStart(change.element, change.start),
|
|
@@ -420,7 +423,7 @@ export function useTimelineGroupEditing({
|
|
|
420
423
|
coalesceKey,
|
|
421
424
|
coalesceMs,
|
|
422
425
|
});
|
|
423
|
-
if (!
|
|
426
|
+
if (!sdk) {
|
|
424
427
|
await persistServerBatch(
|
|
425
428
|
projectId,
|
|
426
429
|
"Resize timeline clips",
|
|
@@ -451,6 +454,7 @@ export function useTimelineGroupEditing({
|
|
|
451
454
|
writeProjectFile,
|
|
452
455
|
activeCompPath,
|
|
453
456
|
changes,
|
|
457
|
+
sdkGsap: sdk?.sdkGsap,
|
|
454
458
|
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
|
455
459
|
mutateChange: (change, changePath) => {
|
|
456
460
|
const domId = change.element.domId;
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
elementScroll,
|
|
7
|
+
observeElementOffset,
|
|
8
|
+
observeElementRect,
|
|
9
|
+
Virtualizer,
|
|
10
|
+
} from "@tanstack/react-virtual";
|
|
11
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
6
12
|
import { createTimelineRowGeometry, RULER_H, TRACKS_TOP_PAD } from "./timelineLayout";
|
|
7
13
|
import { extractTimelineVirtualRowRange, useTimelineVirtualRows } from "./useTimelineVirtualRows";
|
|
8
14
|
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
|
@@ -203,4 +209,27 @@ describe("useTimelineVirtualRows", () => {
|
|
|
203
209
|
expect(rows.some((row) => row.index === 500)).toBe(false);
|
|
204
210
|
act(() => root.unmount());
|
|
205
211
|
});
|
|
212
|
+
|
|
213
|
+
// A scroll-end timer left running fires into the torn-down test window ("window is not defined").
|
|
214
|
+
it("gets no virtualizer change after unmount from a scroll just before it", async () => {
|
|
215
|
+
const scroll = createScrollElement(0);
|
|
216
|
+
const onChange = vi.fn();
|
|
217
|
+
const virtualizer = new Virtualizer<HTMLDivElement, Element>({
|
|
218
|
+
count: 100,
|
|
219
|
+
getScrollElement: () => scroll,
|
|
220
|
+
estimateSize: () => 48,
|
|
221
|
+
scrollToFn: elementScroll,
|
|
222
|
+
observeElementRect,
|
|
223
|
+
observeElementOffset,
|
|
224
|
+
onChange,
|
|
225
|
+
});
|
|
226
|
+
const unmount = virtualizer._didMount();
|
|
227
|
+
virtualizer._willUpdate();
|
|
228
|
+
scroll.dispatchEvent(new Event("scroll"));
|
|
229
|
+
unmount();
|
|
230
|
+
onChange.mockClear();
|
|
231
|
+
|
|
232
|
+
await new Promise((settle) => setTimeout(settle, 200));
|
|
233
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
234
|
+
});
|
|
206
235
|
});
|
package/src/utils/rounding.ts
CHANGED
|
@@ -3,6 +3,11 @@ export function roundTo3(val: number): number {
|
|
|
3
3
|
return Math.round(val * 1000) / 1000;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
/** Whole CSS px: GSAP parses a -50% translate against the integer offsetWidth/Height. */
|
|
7
|
+
export function roundToLayoutPx(val: number): number {
|
|
8
|
+
return Math.round(val);
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
/** Round to 2 decimal places (centisecond precision for timeline values). */
|
|
7
12
|
export function roundToCenti(val: number): number {
|
|
8
13
|
return Math.round(val * 100) / 100;
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
|
|
12
12
|
export type CutoverResult =
|
|
13
13
|
| { status: "declined"; reason: string }
|
|
14
|
-
| { status: "committed"; version: string }
|
|
14
|
+
| { status: "committed"; version: string; before: string; after: string }
|
|
15
15
|
| { status: "failed"; error: Error };
|
|
16
16
|
|
|
17
17
|
export interface SdkSessionPublication {
|
|
@@ -143,7 +143,9 @@ export function failedCutover(
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/** Only an explicit decline may enter the legacy mutation backend. */
|
|
146
|
-
export function cutoverCommittedOrThrow(
|
|
146
|
+
export function cutoverCommittedOrThrow(
|
|
147
|
+
result: CutoverResult,
|
|
148
|
+
): result is Extract<CutoverResult, { status: "committed" }> {
|
|
147
149
|
if (result.status === "failed") throw result.error;
|
|
148
150
|
return result.status === "committed";
|
|
149
151
|
}
|
|
@@ -300,7 +302,12 @@ async function commitCandidateEdit(
|
|
|
300
302
|
});
|
|
301
303
|
}
|
|
302
304
|
if (refreshTarget) refreshCommittedEdit(edit.after, deps, options);
|
|
303
|
-
return {
|
|
305
|
+
return {
|
|
306
|
+
status: "committed",
|
|
307
|
+
version: hashContent(edit.after),
|
|
308
|
+
before: originalContent,
|
|
309
|
+
after: edit.after,
|
|
310
|
+
};
|
|
304
311
|
}
|
|
305
312
|
|
|
306
313
|
export async function persistSdkCandidateMutation(
|