Skip to content

Commit decff5d

Browse files
committed
feat(studio): route static position/rotation set drags through instantPatch
Static-element position and rotation set commits now attach instantPatch{selector, change:{kind:set}} so the drag updates in place with no reload. Structural ops (new tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit it and keep the soft reload — keyframe instant-patch needs object-form keyframe support in patchRuntimeTweenInPlace (deferred).
1 parent a3508f2 commit decff5d

3 files changed

Lines changed: 195 additions & 2 deletions

File tree

packages/studio/src/hooks/gsapDragCommit.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
33
import type { DomEditSelection } from "../components/editor/domEditingTypes";
44
import {
55
commitGsapPositionFromDrag,
6+
commitStaticGsapPosition,
7+
commitStaticGsapRotation,
68
parkPlayheadOnKeyframe,
79
type GsapDragCommitCallbacks,
810
} from "./gsapDragCommit";
@@ -235,6 +237,142 @@ describe("commitGsapPositionFromDrag — from() tween dragged outside its range"
235237
});
236238
});
237239

240+
// Captures the OPTIONS each commit carries (not just the mutation) so we can
241+
// assert which value-only commits attach the `instantPatch` fast path.
242+
type RecordedCommit = { mutation: Record<string, unknown>; options: Record<string, unknown> };
243+
function optionRecordingCallbacks(): {
244+
commits: RecordedCommit[];
245+
callbacks: GsapDragCommitCallbacks;
246+
} {
247+
const commits: RecordedCommit[] = [];
248+
return {
249+
commits,
250+
callbacks: {
251+
commitMutation: async (_sel, mutation, options) => {
252+
commits.push({ mutation, options: options as Record<string, unknown> });
253+
},
254+
fetchAnimations: async () => [convertedTween()],
255+
},
256+
};
257+
}
258+
259+
const existingPositionSet = (): GsapAnimation =>
260+
({
261+
id: "#puck-a-set",
262+
targetSelector: "#puck-a",
263+
method: "set",
264+
properties: { x: 10, y: 20 },
265+
}) as unknown as GsapAnimation;
266+
267+
const existingRotationSet = (): GsapAnimation =>
268+
({
269+
id: "#puck-a-rot-set",
270+
targetSelector: "#puck-a",
271+
method: "set",
272+
properties: { rotation: 15 },
273+
}) as unknown as GsapAnimation;
274+
275+
describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
276+
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
277+
278+
it("attaches instantPatch {kind:set, props:{x,y}} to the FINAL coalesced commit only", async () => {
279+
const { commits, callbacks } = optionRecordingCallbacks();
280+
281+
await commitStaticGsapPosition(
282+
selection(),
283+
{ x: -50, y: 30 }, // studioOffset → newX/newY off a zero base
284+
{ x: 0, y: 0 },
285+
"#puck-a",
286+
existingPositionSet(),
287+
callbacks,
288+
);
289+
290+
expect(commits).toHaveLength(2);
291+
// First (x) commit is the intermediate skipReload one — NO instantPatch.
292+
expect(commits[0].options.skipReload).toBe(true);
293+
expect(commits[0].options.instantPatch).toBeUndefined();
294+
// Final (y) commit triggers the reload and carries the full {x,y} patch.
295+
expect(commits[1].options.softReload).toBe(true);
296+
expect(commits[1].options.instantPatch).toEqual({
297+
selector: "#puck-a",
298+
change: { kind: "set", props: { x: -50, y: 30 } },
299+
});
300+
});
301+
302+
it("does NOT attach instantPatch when ADDING a new set (structural — new tween)", async () => {
303+
const { commits, callbacks } = optionRecordingCallbacks();
304+
305+
await commitStaticGsapPosition(
306+
selection(),
307+
{ x: -50, y: 30 },
308+
{ x: 0, y: 0 },
309+
"#puck-a",
310+
null, // no existing set → `add` a new tween
311+
callbacks,
312+
);
313+
314+
expect(commits).toHaveLength(1);
315+
expect(commits[0].mutation.type).toBe("add");
316+
expect(commits[0].options.instantPatch).toBeUndefined();
317+
});
318+
});
319+
320+
describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
321+
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
322+
323+
it("attaches instantPatch {kind:set, props:{rotation}} when updating an existing rotation set", async () => {
324+
const { commits, callbacks } = optionRecordingCallbacks();
325+
326+
await commitStaticGsapRotation(selection(), 42, "#puck-a", existingRotationSet(), callbacks);
327+
328+
expect(commits).toHaveLength(1);
329+
expect(commits[0].mutation.type).toBe("update-property");
330+
expect(commits[0].options.instantPatch).toEqual({
331+
selector: "#puck-a",
332+
change: { kind: "set", props: { rotation: 42 } },
333+
});
334+
});
335+
336+
it("does NOT attach instantPatch when ADDING a new rotation set (structural)", async () => {
337+
const { commits, callbacks } = optionRecordingCallbacks();
338+
339+
await commitStaticGsapRotation(selection(), 42, "#puck-a", null, callbacks);
340+
341+
expect(commits).toHaveLength(1);
342+
expect(commits[0].mutation.type).toBe("add");
343+
expect(commits[0].options.instantPatch).toBeUndefined();
344+
});
345+
});
346+
347+
describe("commitGsapPositionFromDrag — keyframe/structural commits omit instantPatch", () => {
348+
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
349+
350+
it("a structural keyframe drag (convert-to-keyframes → add-keyframe) sets no instantPatch", async () => {
351+
usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4] → convert + add-keyframe
352+
const { commits, callbacks } = optionRecordingCallbacks();
353+
354+
await commitGsapPositionFromDrag(
355+
selection(),
356+
flatTween(),
357+
{ x: -100, y: 0 },
358+
{ x: 0, y: 0 },
359+
null,
360+
"#puck-a",
361+
callbacks,
362+
);
363+
364+
// The keyframe path is structural here (convert + add-keyframe) and must rely
365+
// on the soft reload — none of its commits opt into the instant patch.
366+
expect(commits.length).toBeGreaterThan(0);
367+
for (const c of commits) {
368+
expect(c.options.instantPatch).toBeUndefined();
369+
}
370+
const types = commits.map((c) => c.mutation.type);
371+
expect(types).toContain("convert-to-keyframes");
372+
expect(types).toContain("add-keyframe");
373+
});
374+
});
375+
238376
describe("parkPlayheadOnKeyframe", () => {
239377
beforeEach(() => usePlayerStore.setState({ requestedSeekTime: null }));
240378

packages/studio/src/hooks/gsapDragCommit.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeComp
1010
import { roundTo3 } from "../utils/rounding";
1111
import { computeElementPercentage } from "./gsapShared";
1212
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
13+
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
1314
export interface GsapDragCommitCallbacks {
1415
commitMutation: (
1516
selection: DomEditSelection,
@@ -20,6 +21,13 @@ export interface GsapDragCommitCallbacks {
2021
softReload?: boolean;
2122
skipReload?: boolean;
2223
beforeReload?: () => void;
24+
/**
25+
* Value-only fast path: when set, `runCommit` patches the changed tween in
26+
* the preview runtime in place (instant, no re-run) and only falls back to
27+
* the soft reload if the patch can't be safely applied. Attached only to
28+
* value-only `set` commits; structural/keyframe commits omit it.
29+
*/
30+
instantPatch?: { selector: string; change: RuntimeTweenChange };
2331
},
2432
) => Promise<void>;
2533
fetchAnimations?: () => Promise<GsapAnimation[]>;
@@ -351,7 +359,15 @@ export async function commitStaticGsapPosition(
351359
await callbacks.commitMutation(
352360
selection,
353361
{ type: "update-property", animationId: existingSet.id, property: "y", value: newY },
354-
{ label: "Move layer", softReload: true, coalesceKey },
362+
{
363+
label: "Move layer",
364+
softReload: true,
365+
coalesceKey,
366+
// Final commit of the coalesced x/y pair: carry the full {x,y} so the
367+
// runtime `tl.set` is patched in place instantly (skips the soft reload
368+
// when the helper can confidently apply it).
369+
instantPatch: { selector, change: { kind: "set", props: { x: newX, y: newY } } },
370+
},
355371
);
356372
return;
357373
}
@@ -405,7 +421,12 @@ export async function commitStaticGsapRotation(
405421
property: "rotation",
406422
value: newRotation,
407423
},
408-
{ label: "Rotate layer", softReload: true },
424+
{
425+
label: "Rotate layer",
426+
softReload: true,
427+
// Value-only rotation set: patch the runtime `tl.set` rotation in place.
428+
instantPatch: { selector, change: { kind: "set", props: { rotation: newRotation } } },
429+
},
409430
);
410431
return;
411432
}

packages/studio/src/hooks/gsapRuntimeBridge.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,40 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
8989
expect(mutation.type).not.toBe("add-keyframe");
9090
});
9191

92+
it("forwards instantPatch {kind:set,x,y} on the final commit when updating an existing static set", async () => {
93+
const commitMutation = vi.fn();
94+
const iframe = fakeIframe("puck-b", []); // runtime empty → STATIC path
95+
// An existing position-hold `set` for the selector → update-in-place (not add).
96+
const existingSet = {
97+
id: "#puck-b-set",
98+
targetSelector: "#puck-b",
99+
method: "set",
100+
// Tagged as a position group so resolveGroupTween returns it directly
101+
// (no split commit), exercising the in-place update path cleanly.
102+
propertyGroup: "position",
103+
properties: { x: 0, y: 0 },
104+
} as unknown as GsapAnimation;
105+
106+
const handled = await tryGsapDragIntercept(
107+
selection,
108+
{ x: -50, y: 30 },
109+
[existingSet],
110+
iframe,
111+
commitMutation,
112+
);
113+
114+
expect(handled).toBe(true);
115+
// The coalesced update-property pair: the x commit is skipReload (no patch),
116+
// the final y commit triggers the reload and carries the full {x,y} patch.
117+
const updates = commitMutation.mock.calls.filter(([, m]) => m.type === "update-property");
118+
expect(updates).toHaveLength(2);
119+
expect(updates[0][2].instantPatch).toBeUndefined();
120+
expect(updates[1][2].instantPatch).toEqual({
121+
selector: "#puck-b",
122+
change: { kind: "set", props: { x: -50, y: 30 } },
123+
});
124+
});
125+
92126
it("does not trip the stale-parse guard when the runtime still has the tween", async () => {
93127
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
94128
const liveTween = {

0 commit comments

Comments
 (0)