fix(studio): respect GSAP transform ownership (#2986)

* fix(studio): respect GSAP transform ownership

* fix(studio): enforce GSAP edit ownership consistently
This commit is contained in:
Miguel Ángel
2026-08-04 19:08:02 +00:00
committed by GitHub
parent 532f06158b
commit cf45c98454
14 changed files with 916 additions and 224 deletions
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { describe, expect, it, beforeEach } from "vitest";
import { describe, expect, it, beforeEach, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
@@ -9,6 +9,7 @@ import {
commitStaticGsapRotation,
commitStaticGsapSize,
findExistingPositionWrite,
materializeIfDynamic,
parkPlayheadOnKeyframe,
type GsapDragCommitCallbacks,
} from "./gsapDragCommit";
@@ -27,6 +28,43 @@ const selection = (): DomEditSelection =>
},
}) as unknown as DomEditSelection;
function selectorlessSelection(): DomEditSelection {
return {
selector: ".shared",
element: document.createElement("div"),
} as unknown as DomEditSelection;
}
describe("lower GSAP commit helpers fail closed", () => {
it("rejects instead of silently dropping a static position write without a stable selector", async () => {
const commitMutation = vi.fn();
await expect(
commitStaticGsapPosition(
selectorlessSelection(),
{ x: 10, y: 20 },
{ x: 0, y: 0 },
".shared",
null,
{ commitMutation },
),
).rejects.toMatchObject({ name: "GsapEditBlockedError", reason: "no-selector" });
expect(commitMutation).not.toHaveBeenCalled();
});
it("rejects runtime-dynamic materialization instead of rewriting source during a gesture", async () => {
const commitMutation = vi.fn();
await expect(
materializeIfDynamic(
{ ...flatTween(), hasUnresolvedKeyframes: true },
null,
commitMutation,
selection(),
),
).rejects.toMatchObject({ name: "GsapEditBlockedError", reason: "source-uneditable" });
expect(commitMutation).not.toHaveBeenCalled();
});
});
const flatTween = (): GsapAnimation =>
({
id: "#puck-a-to",
+12 -40
View File
@@ -9,14 +9,14 @@ import {
STUDIO_ORIGINAL_HEIGHT_ATTR,
} from "../components/editor/manualEditsTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import { computeElementPercentage, idSelector, writeTargetSelector } from "./gsapShared";
import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
import { GsapEditBlockedError } from "./gsapEditOutcome";
export {
findExistingPositionWrite,
findRotationSetAnimation,
@@ -87,7 +87,7 @@ async function replaceKeyframedPositionHold(
commitMutation: GsapDragCommitCallbacks["commitMutation"],
): Promise<void> {
const target = newTweenTarget(selection);
if (!target) return;
if (!target) throw new GsapEditBlockedError("no-selector");
const persist = async (commit: GsapDragCommitCallbacks["commitMutation"]) => {
await commit(
selection,
@@ -130,40 +130,12 @@ export async function materializeIfDynamic(
selection: DomEditSelection,
): Promise<string | void> {
if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
if (anim.hasUnresolvedSelector) {
const allScanned = scanAllRuntimeKeyframes(iframe);
if (allScanned.size === 0) return;
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
selector: idSelector(id),
keyframes: data.keyframes,
easeEach: data.easeEach,
}));
await commitMutation(
selection,
{
type: "materialize-keyframes",
animationId: anim.id,
keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
allElements,
},
{ label: "Unroll dynamic animations", skipReload: true },
);
return `${anim.targetSelector}-to-0`;
}
const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
if (!runtime || runtime.keyframes.length === 0) return;
await commitMutation(
selection,
{
type: "materialize-keyframes",
animationId: anim.id,
keyframes: runtime.keyframes,
easeEach: runtime.easeEach,
},
{ label: "Materialize dynamic keyframes", skipReload: true },
);
// Geometry commits must never rewrite runtime/computed source implicitly.
// The explicit Unroll action owns that source-destructive transition.
void iframe;
void commitMutation;
void selection;
throw new GsapEditBlockedError("source-uneditable");
}
// ── Drag → GSAP position math ──────────────────────────────────────────────
@@ -219,7 +191,7 @@ export async function commitStaticGsapPosition(
// The patch reuses the WRITTEN target so the runtime moves exactly the element
// the source write names.
const target = newTweenTarget(selection);
if (!target) return;
if (!target) throw new GsapEditBlockedError("no-selector");
await callbacks.commitMutation(
selection,
{
@@ -277,7 +249,7 @@ export async function commitStaticGsapRotation(
}
// New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch.
const target = newTweenTarget(selection);
if (!target) return;
if (!target) throw new GsapEditBlockedError("no-selector");
await callbacks.commitMutation(
selection,
{
@@ -331,7 +303,7 @@ export async function commitStaticGsapSize(
return;
}
const target = newTweenTarget(selection);
if (!target) return;
if (!target) throw new GsapEditBlockedError("no-selector");
await callbacks.commitMutation(
selection,
{
@@ -0,0 +1,72 @@
import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/gsap-parser";
export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
export type GsapEditOutcome =
| { status: "persisted" }
| { status: "blocked"; reason: GsapEditBlockReason };
const COPY: Record<GsapEditBlockReason, string> = {
"no-selector": "This layer needs a stable selector before Studio can save the edit.",
"unroll-required":
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
"source-uneditable": "This animation is computed at runtime. Edit the animation in the Code tab.",
};
export class GsapEditBlockedError extends Error {
constructor(readonly reason: GsapEditBlockReason) {
super(COPY[reason]);
this.name = "GsapEditBlockedError";
}
}
export function assertGsapEditPersisted(outcome: GsapEditOutcome): void {
if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason);
}
function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void {
const editability = editabilityForProvenance(animation.provenance);
if (editability === "unroll") throw new GsapEditBlockedError("unroll-required");
if (
editability === "source" ||
animation.hasUnresolvedKeyframes ||
animation.hasUnresolvedSelector
) {
throw new GsapEditBlockedError("source-uneditable");
}
}
export function isGsapEditBlockedError(error: unknown): error is GsapEditBlockedError {
return error instanceof GsapEditBlockedError;
}
export function animationWritesAnyProperty(
animation: GsapAnimation,
properties: ReadonlySet<string>,
): boolean {
return (
Object.keys(animation.properties ?? {}).some((property) => properties.has(property)) ||
Object.keys(animation.fromProperties ?? {}).some((property) => properties.has(property)) ||
!!animation.keyframes?.keyframes.some((keyframe) =>
Object.keys(keyframe.properties).some((property) => properties.has(property)),
)
);
}
/** Fail-closed ownership check shared by drag, resize, rotate, and inspector edits. */
export function directEditOutcomeForProperties(
animations: GsapAnimation[],
properties: ReadonlySet<string>,
): GsapEditOutcome {
try {
for (const animation of animations) {
if (animationWritesAnyProperty(animation, properties)) {
assertGsapAnimationDirectlyEditable(animation);
}
}
return { status: "persisted" };
} catch (error) {
if (isGsapEditBlockedError(error)) return { status: "blocked", reason: error.reason };
throw error;
}
}
@@ -70,6 +70,8 @@ function keyframedScaleFixture(): GsapAnimation {
} as unknown as GsapAnimation;
}
// Resize/rotation hold tests intentionally pin the same no-conversion contract.
// fallow-ignore-next-line code-duplication
it("updates a duration-zero size hold in place instead of converting it to keyframes", async () => {
const el = document.createElement("div");
el.id = "box";
@@ -96,7 +98,7 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
commitMutation,
);
expect(handled).toBe(true);
expect(handled).toEqual({ status: "persisted" });
expect(commitMutation).toHaveBeenCalledTimes(1);
expect(commitMutation.mock.calls[0]![1]).toEqual({
type: "update-properties",
@@ -115,6 +117,92 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
);
});
// fallow-ignore-next-line code-duplication
it("requires explicit unroll for helper-authored resize before mutating", async () => {
const el = document.createElement("div");
el.id = "box";
document.body.append(el);
const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
const helperSize = {
id: "#box-to-size",
targetSelector: "#box",
propertyGroup: "size",
method: "to",
properties: { width: 150, height: 150 },
duration: 1,
provenance: { kind: "helper", fn: "grow", callSite: 1 },
} as unknown as GsapAnimation;
const commitMutation = vi.fn();
await expect(
tryGsapResizeIntercept(
selection,
{ width: 344, height: 344 },
[helperSize],
null,
commitMutation,
),
).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
expect(commitMutation).not.toHaveBeenCalled();
});
// fallow-ignore-next-line code-duplication
it("reuses the ownership parse instead of fetching a resolved size group twice", async () => {
const el = document.createElement("div");
el.id = "box";
document.body.append(el);
const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
const sizeHold = {
id: "#box-set-size",
targetSelector: "#box",
propertyGroup: "size",
method: "set",
properties: { width: 150, height: 150 },
} as unknown as GsapAnimation;
const fetchAnimations = vi.fn().mockResolvedValue([sizeHold]);
const commitMutation = vi.fn();
await expect(
tryGsapResizeIntercept(
selection,
{ width: 344, height: 344 },
[],
null,
commitMutation,
fetchAnimations,
),
).resolves.toEqual({ status: "persisted" });
expect(fetchAnimations).toHaveBeenCalledTimes(1);
expect(commitMutation).toHaveBeenCalledWith(
selection,
expect.objectContaining({ type: "update-properties", animationId: sizeHold.id }),
expect.anything(),
);
});
it("blocks when runtime size motion exists but the authored tween cannot be resolved", async () => {
const el = document.createElement("div");
el.id = "box";
document.body.append(el);
const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
const liveSizeTween = {
targets: () => [el],
vars: { width: 300, duration: 1 },
duration: () => 1,
startTime: () => 0,
};
const iframe = {
contentWindow: { __timelines: { main: { getChildren: () => [liveSizeTween] } } },
contentDocument: document,
} as unknown as HTMLIFrameElement;
const commitMutation = vi.fn();
await expect(
tryGsapResizeIntercept(selection, { width: 344, height: 344 }, [], iframe, commitMutation),
).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
expect(commitMutation).not.toHaveBeenCalled();
});
it("computes a finite zero percentage for a zero-duration tween", () => {
const animation = {
id: "#box-to-0-size",
@@ -158,7 +246,7 @@ async function runResize(
commitMutation as never,
async () => [keyframedScaleFixture()],
);
expect(handled).toBe(true);
expect(handled).toEqual({ status: "persisted" });
return committed;
}
@@ -24,11 +24,16 @@ import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { isInstantHold, selectorFromSelection } from "./gsapShared";
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
import { roundTo3 } from "../utils/rounding";
import { resolveGroupTween, POSITION_CHANNELS } from "./gsapRuntimeBridge";
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
import { logResize } from "../utils/resizeDebug";
import {
animationWritesAnyProperty,
directEditOutcomeForProperties,
type GsapEditOutcome,
} from "./gsapEditOutcome";
const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
@@ -54,21 +59,44 @@ export async function tryGsapResizeIntercept(
iframe: HTMLIFrameElement | null,
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<boolean> {
): Promise<GsapEditOutcome> {
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
const allKnownAnimations = [...animations, ...fetchedAnimations];
// If the element already has a scale-group tween, resize should modify scale
// (the user is resizing something whose visual size is driven by scale).
// Otherwise, use the size group (width/height).
const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale");
const hasScaleGroup = allKnownAnimations.some((a) => a.propertyGroup === "scale");
const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
const resizeProperties =
resizeGroup === "scale" ? new Set(["scale", "scaleX", "scaleY"]) : new Set(["width", "height"]);
const editability = directEditOutcomeForProperties(allKnownAnimations, resizeProperties);
if (editability.status === "blocked") return editability;
const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
// The initial ownership fetch already supplied the complete parse. Only retain
// the fetch callback when a legacy mixed tween may be split and must then be
// re-read; otherwise resolveGroupTween would perform the same network read twice.
const postSplitFetch = workingAnimations.some((animation) => !animation.propertyGroup)
? fetchFallbackAnimations
: undefined;
const resolved = await resolveGroupTween(
resizeGroup,
animations,
workingAnimations,
selection,
commitMutation,
fetchFallbackAnimations,
postSplitFetch,
);
let anim = resolved?.anim ?? null;
let anim =
resolved?.anim && animationWritesAnyProperty(resolved.anim, resizeProperties)
? resolved.anim
: null;
const liveSelector = selectorFromSelection(selection);
const hasLiveResizeTween = liveSelector
? hasNonHoldTweenForElement(iframe, liveSelector, undefined, [...resizeProperties])
: false;
if (!anim && hasLiveResizeTween) {
return { status: "blocked", reason: "source-uneditable" };
}
logResize("intercept-enter", {
hasScaleGroup,
resizeGroup,
@@ -77,16 +105,16 @@ export async function tryGsapResizeIntercept(
size,
});
if (!anim || isInstantHold(anim)) {
const sel = selectorFromSelection(selection);
if (!sel) return false;
const sizeSet = anim ?? findSizeSetAnimation(animations, sel, selection.element);
const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
if (!sel) return { status: "blocked", reason: "no-selector" };
const sizeSet = anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element);
// If the element is animated (has a real tween, not just a static size
// hold), keyframe the size at the playhead so other keyframes keep theirs —
// instead of a global set that resizes every frame.
if (resizeGroup === "size") {
const animatedTween = pickClosestToPlayhead(
animations.filter((a) => !isInstantHold(a) && resolveTweenDuration(a) > 0),
workingAnimations.filter((a) => !isInstantHold(a) && resolveTweenDuration(a) > 0),
);
if (animatedTween) {
logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
@@ -98,7 +126,7 @@ export async function tryGsapResizeIntercept(
animatedTween,
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
);
if (handled) return true;
if (handled) return { status: "persisted" };
}
}
@@ -107,11 +135,11 @@ export async function tryGsapResizeIntercept(
commitMutation,
fetchAnimations: fetchFallbackAnimations,
});
return true;
return { status: "persisted" };
}
const tweenDuration = resolveTweenDuration(anim);
if (tweenDuration <= 0) return false;
if (tweenDuration <= 0) return { status: "blocked", reason: "source-uneditable" };
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
@@ -273,7 +301,7 @@ export async function tryGsapResizeIntercept(
"Resize animation",
);
await finalizeScaleResizeCommit();
return true;
return { status: "persisted" };
}
const ct = usePlayerStore.getState().currentTime;
@@ -385,7 +413,7 @@ export async function tryGsapResizeIntercept(
},
);
await finalizeScaleResizeCommit();
return true;
return { status: "persisted" };
}
const SIZE_PROPS = new Set(["width", "height"]);
@@ -407,7 +435,7 @@ export async function tryGsapResizeIntercept(
{ label: `Resize (keyframe ${pct}%)`, softReload: true },
);
await finalizeScaleResizeCommit();
return true;
return { status: "persisted" };
}
// ── Rotation intercept ────────────────────────────────────────────────────
@@ -59,6 +59,67 @@ const stalePositionAnim = {
afterEach(() => vi.restoreAllMocks());
describe("tryGsapDragIntercept — stale-parse guard (no resurrection after delete-all)", () => {
async function runHelperOwnedDrag(fetchFallbackAnimations?: () => Promise<GsapAnimation[]>) {
const helperTween = {
...stalePositionAnim,
provenance: { kind: "helper", fn: "slam", callSite: 1 },
} as GsapAnimation;
const commitMutation = vi.fn();
const result = await tryGsapDragIntercept(
selection,
{ x: 10, y: 10 },
[helperTween],
fakeIframe("puck-b", []),
commitMutation,
fetchFallbackAnimations,
);
return { result, commitMutation };
}
it("blocks a live runtime position tween when no editable source mapping exists", async () => {
const liveTween = {
targets: () => [{ id: "puck-b" }],
vars: { y: 18, duration: 1 },
duration: () => 1,
startTime: () => 0,
};
const commitMutation = vi.fn();
const result = await tryGsapDragIntercept(
selection,
{ x: 25, y: -10 },
[],
fakeIframe("puck-b", [liveTween]),
commitMutation,
vi.fn().mockResolvedValue([]),
);
expect(result).toEqual({ status: "blocked", reason: "source-uneditable" });
expect(commitMutation).not.toHaveBeenCalled();
});
it("blocks a selector that cannot safely address one writable element", async () => {
const selectorless = {
...selection,
id: undefined,
selector: undefined,
} as unknown as DomEditSelection;
const result = await tryGsapDragIntercept(selectorless, { x: 1, y: 1 }, [], null, vi.fn());
expect(result).toEqual({ status: "blocked", reason: "no-selector" });
});
it("requires explicit unroll for helper-authored source and performs no mutation", async () => {
const { result, commitMutation } = await runHelperOwnedDrag();
expect(result).toEqual({ status: "blocked", reason: "unroll-required" });
expect(commitMutation).not.toHaveBeenCalled();
});
it("does not let an empty fallback response bypass cached helper provenance", async () => {
const { result, commitMutation } = await runHelperOwnedDrag(vi.fn().mockResolvedValue([]));
expect(result).toEqual({ status: "blocked", reason: "unroll-required" });
expect(commitMutation).not.toHaveBeenCalled();
});
it("commits a static set (not the stale tween) when the runtime has no live position motion", async () => {
const commitMutation = vi.fn();
// Runtime empty (tween deleted) — readRuntimeKeyframes returns null, so the
@@ -73,7 +134,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
commitMutation,
);
expect(handled).toBe(true);
expect(handled).toEqual({ status: "persisted" });
// No existing `set` for the selector → one `add` mutation with `method:"set"`.
expect(commitMutation).toHaveBeenCalledTimes(1);
const [, mutation] = commitMutation.mock.calls[0];
@@ -112,7 +173,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
commitMutation,
);
expect(handled).toBe(true);
expect(handled).toEqual({ status: "persisted" });
const updates = commitMutation.mock.calls.filter(([, m]) => m.type === "update-properties");
expect(updates).toHaveLength(1);
expect(updates[0][1]).toEqual({
@@ -151,7 +212,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
commitMutation,
);
expect(handled).toBe(true);
expect(handled).toEqual({ status: "persisted" });
// One atomic in-place update, NOT an `add`/`add-keyframe`.
const types = commitMutation.mock.calls.map(([, m]) => m.type);
expect(types).toEqual(["update-properties"]);
@@ -179,6 +240,124 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
});
describe("tryGsapRotationIntercept — instant holds", () => {
it("requires explicit unroll for helper-authored rotation before mutating", async () => {
const helperRotation = {
id: "#puck-b-to-rotation",
targetSelector: "#puck-b",
propertyGroup: "rotation",
method: "to",
properties: { rotation: 45 },
position: 0,
duration: 1,
provenance: { kind: "helper", fn: "spin", callSite: 1 },
} as unknown as GsapAnimation;
const commitMutation = vi.fn();
await expect(
tryGsapRotationIntercept(selection, 75, [helperRotation], null, commitMutation),
).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
expect(commitMutation).not.toHaveBeenCalled();
});
// Mirrors resize by design: both geometry routes must reuse one ownership parse.
// fallow-ignore-next-line code-duplication
it("reuses the ownership parse instead of fetching a resolved rotation group twice", async () => {
const rotationHold = {
id: "#puck-b-set-rotation",
targetSelector: "#puck-b",
propertyGroup: "rotation",
method: "set",
properties: { rotation: 45 },
} as unknown as GsapAnimation;
const fetchAnimations = vi.fn().mockResolvedValue([rotationHold]);
const commitMutation = vi.fn();
await expect(
tryGsapRotationIntercept(selection, 75, [], null, commitMutation, fetchAnimations),
).resolves.toEqual({ status: "persisted" });
expect(fetchAnimations).toHaveBeenCalledTimes(1);
expect(commitMutation).toHaveBeenCalledWith(
selection,
expect.objectContaining({ type: "update-property", animationId: rotationHold.id }),
expect.anything(),
);
});
it("rejects a selectorless rotation instead of reporting a handled no-op", async () => {
const selectorless = {
...selection,
id: undefined,
selector: undefined,
} as unknown as DomEditSelection;
const commitMutation = vi.fn();
await expect(
tryGsapRotationIntercept(selectorless, 75, [], null, commitMutation),
).resolves.toEqual({ status: "blocked", reason: "no-selector" });
expect(commitMutation).not.toHaveBeenCalled();
});
it("blocks helper-authored 3D rotation channels before mutating the group", async () => {
const helperRotationX = {
id: "#puck-b-to-rotation",
targetSelector: "#puck-b",
propertyGroup: "rotation",
method: "to",
properties: { rotationX: 45 },
duration: 1,
provenance: { kind: "helper", fn: "tilt", callSite: 1 },
} as unknown as GsapAnimation;
const commitMutation = vi.fn();
await expect(
tryGsapRotationIntercept(selection, 75, [helperRotationX], null, commitMutation),
).resolves.toEqual({ status: "blocked", reason: "unroll-required" });
expect(commitMutation).not.toHaveBeenCalled();
});
it("does not let an unrelated helper-authored skew tween block 2D rotation", async () => {
const helperSkew = {
id: "#puck-b-to-rotation",
targetSelector: "#puck-b",
propertyGroup: "rotation",
method: "to",
properties: { skewX: 12 },
duration: 1,
provenance: { kind: "helper", fn: "skew", callSite: 1 },
} as unknown as GsapAnimation;
const commitMutation = vi.fn();
await expect(
tryGsapRotationIntercept(selection, 75, [helperSkew], null, commitMutation),
).resolves.toEqual({ status: "persisted" });
expect(commitMutation).toHaveBeenCalledWith(
selection,
expect.objectContaining({ type: "add", properties: { rotation: 75 } }),
expect.anything(),
);
});
it("blocks when runtime rotation exists but the authored tween cannot be resolved", async () => {
const liveRotation = {
targets: () => [{ id: "puck-b" }],
vars: { rotation: 45, duration: 1 },
duration: () => 1,
startTime: () => 0,
};
const commitMutation = vi.fn();
await expect(
tryGsapRotationIntercept(
selection,
75,
[],
fakeIframe("puck-b", [liveRotation]),
commitMutation,
),
).resolves.toEqual({ status: "blocked", reason: "source-uneditable" });
expect(commitMutation).not.toHaveBeenCalled();
});
it("updates a duration-zero fromTo hold instead of converting it to keyframes", async () => {
const rotationHold = {
id: "#puck-b-fromTo-0-rotation",
@@ -201,7 +380,7 @@ describe("tryGsapRotationIntercept — instant holds", () => {
commitMutation,
);
expect(handled).toBe(true);
expect(handled).toEqual({ status: "persisted" });
expect(commitMutation).toHaveBeenCalledTimes(1);
expect(commitMutation.mock.calls[0]![1]).toEqual({
type: "update-property",
@@ -243,40 +422,29 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
},
} as unknown as GsapAnimation;
it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 2 }); // playhead at 100%
async function runAutoKeyframeDrag(enabled: boolean) {
usePlayerStore.setState({ autoKeyframeEnabled: enabled, currentTime: 2 });
const commitMutation = vi.fn();
const iframe = fakeIframe("puck-b", []);
const handled = await tryGsapDragIntercept(
selection,
{ x: -50, y: 0 },
[keyframedPositionAnim],
iframe,
fakeIframe("puck-b", []),
commitMutation,
);
return { handled, types: commitMutation.mock.calls.map(([, mutation]) => mutation.type) };
}
expect(handled).toBe(true);
const types = commitMutation.mock.calls.map(([, m]) => m.type);
it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
const { handled, types } = await runAutoKeyframeDrag(false);
expect(handled).toEqual({ status: "persisted" });
expect(types).toContain("replace-with-keyframes");
expect(types).not.toContain("add-keyframe");
});
it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 2 });
const commitMutation = vi.fn();
const iframe = fakeIframe("puck-b", []);
const handled = await tryGsapDragIntercept(
selection,
{ x: -50, y: 0 },
[keyframedPositionAnim],
iframe,
commitMutation,
);
expect(handled).toBe(true);
const types = commitMutation.mock.calls.map(([, m]) => m.type);
const { handled, types } = await runAutoKeyframeDrag(true);
expect(handled).toEqual({ status: "persisted" });
expect(types).not.toContain("replace-with-keyframes");
});
});
@@ -336,7 +504,7 @@ describe("tryGsapDragIntercept — motion paths", () => {
it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => {
const { commitMutation, handled } = await dragMotionPath(null);
expect(handled).toBe(true);
expect(handled).toEqual({ status: "persisted" });
expect(commitMutation).toHaveBeenCalledWith(
selection,
{
@@ -363,7 +531,7 @@ describe("tryGsapDragIntercept — motion paths", () => {
it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
const { commitMutation, handled } = await dragMotionPath(50);
expect(handled).toBe(true);
expect(handled).toEqual({ status: "persisted" });
expect(commitMutation).toHaveBeenCalledWith(
selection,
{
+93 -28
View File
@@ -26,18 +26,23 @@ import {
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { isInstantHold, selectorFromSelection } from "./gsapShared";
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
import {
findGsapPositionAnimation,
pickClosestToPlayhead,
readGsapPositionFromIframe,
} from "./gsapPositionDetection";
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
import {
animationWritesAnyProperty,
directEditOutcomeForProperties,
type GsapEditOutcome,
} from "./gsapEditOutcome";
// Position channels — used to scope the "has a live position tween?" check so a
// sibling rotation/scale animation never forces a static position hold into the
// keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
export const POSITION_CHANNELS = [
export const POSITION_CHANNELS: string[] = [
"x",
"y",
"xPercent",
@@ -50,6 +55,10 @@ export const POSITION_CHANNELS = [
"translateX",
"translateY",
];
const POSITION_CHANNEL_SET = new Set<string>(POSITION_CHANNELS);
const ROTATION_CHANNELS: string[] = ["rotation", "rotationX", "rotationY", "rotationZ"];
const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
// ── Property-group tween resolution ───────────────────────────────────────
@@ -121,11 +130,40 @@ export type { GsapDragCommitCallbacks };
/**
* Attempt to handle a drag commit via the GSAP script mutation path.
*
* Returns a Promise that resolves to true if the drag was handled via GSAP
* (caller should skip the CSS path), or false if no GSAP position animation
* exists.
* Returns an explicit persisted/blocked outcome. Callers must reject blocked
* outcomes so the gesture layer restores its runtime and overlay drafts.
*/
// fallow-ignore-next-line complexity
async function preflightGsapDragIntercept(
selection: DomEditSelection,
animations: GsapAnimation[],
iframe: HTMLIFrameElement | null,
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<GsapEditOutcome> {
const selector = selectorFromSelection(selection);
if (!selector) return { status: "blocked", reason: "no-selector" };
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
// The fallback API currently represents both a definitive empty parse and an
// exhausted fetch failure as `[]`. Keep the selected cache in the preflight
// set as well: ignoring it would let a transient fetch failure bypass helper /
// runtime-source ownership and reach a destructive split or property write.
const allKnownAnimations = [...animations, ...fetchedAnimations];
const editability = directEditOutcomeForProperties(allKnownAnimations, POSITION_CHANNEL_SET);
if (editability.status === "blocked") return editability;
const sourceAnimations = fetchedAnimations.length > 0 ? fetchedAnimations : animations;
const posAnim = findGsapPositionAnimation(sourceAnimations, selector);
const hasLivePosition = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
if (hasLivePosition && !posAnim) {
return { status: "blocked", reason: "source-uneditable" };
}
if (!posAnim && !writeTargetSelector(selection)) {
return { status: "blocked", reason: "no-selector" };
}
return { status: "persisted" };
}
export async function tryGsapDragIntercept(
selection: DomEditSelection,
offset: { x: number; y: number },
@@ -133,12 +171,20 @@ export async function tryGsapDragIntercept(
iframe: HTMLIFrameElement | null,
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
options?: { altKey?: boolean },
): Promise<boolean> {
const selector = selectorFromSelection(selection);
if (!selector) {
return false;
options?: { altKey?: boolean; preflightOnly?: boolean; preflightPassed?: boolean },
): Promise<GsapEditOutcome> {
if (!options?.preflightPassed) {
const preflight = await preflightGsapDragIntercept(
selection,
animations,
iframe,
fetchFallbackAnimations,
);
if (preflight.status === "blocked" || options?.preflightOnly) return preflight;
}
const selector = selectorFromSelection(selection);
// The preflight above proves this; retain a defensive result for DOM churn.
if (!selector) return { status: "blocked", reason: "no-selector" };
// Self-heal: enforce a single position write BEFORE committing. A corrupted
// file can carry 2+ conflicting position writes for one selector (e.g. a
@@ -218,11 +264,11 @@ export async function tryGsapDragIntercept(
commitMutation,
fetchAnimations: fetchFallbackAnimations,
});
return true;
return { status: "persisted" };
}
if (!posAnim) {
return false;
return { status: "blocked", reason: "source-uneditable" };
}
// Verify the anim ID is still valid in the current file. The React-state
@@ -251,7 +297,7 @@ export async function tryGsapDragIntercept(
} else {
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
}
return true;
return { status: "persisted" };
}
// ── Runtime property readers (re-exported for external callers) ───────────
@@ -267,28 +313,47 @@ export async function tryGsapRotationIntercept(
iframe: HTMLIFrameElement | null,
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<boolean> {
const selector = selectorFromSelection(selection);
if (!selector) return false;
): Promise<GsapEditOutcome> {
const selector = selectorFromSelection(selection) ?? writeTargetSelector(selection);
if (!selector) return { status: "blocked", reason: "no-selector" };
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
const editability = directEditOutcomeForProperties(
[...animations, ...fetchedAnimations],
ROTATION_CHANNEL_SET,
);
if (editability.status === "blocked") return editability;
const postSplitFetch = workingAnimations.some((animation) => !animation.propertyGroup)
? fetchFallbackAnimations
: undefined;
// Resolve the rotation-group tween, splitting legacy mixed tweens if needed.
const resolved = await resolveGroupTween(
"rotation",
animations,
workingAnimations,
selection,
commitMutation,
fetchFallbackAnimations,
postSplitFetch,
);
const resolvedAnimations = resolved?.animations ?? animations;
const resolvedAnimations = resolved?.animations ?? workingAnimations;
// Fallback: legacy heuristic for hand-written scripts
let anim = resolved?.anim ?? null;
let anim =
resolved?.anim && animationWritesAnyProperty(resolved.anim, ROTATION_CHANNEL_SET)
? resolved.anim
: null;
if (!anim) {
anim = animations.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
if (!anim && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
anim = fresh.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
}
anim =
workingAnimations.find((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)) ?? null;
}
const liveSelector = selectorFromSelection(selection);
const hasLiveRotationTween = liveSelector
? hasNonHoldTweenForElement(iframe, liveSelector, undefined, ROTATION_CHANNELS)
: false;
if (!anim && hasLiveRotationTween) {
return { status: "blocked", reason: "source-uneditable" };
}
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
@@ -307,7 +372,7 @@ export async function tryGsapRotationIntercept(
commitMutation,
fetchAnimations: fetchFallbackAnimations,
});
return true;
return { status: "persisted" };
}
const pct = computeCurrentPercentage(selection, anim);
@@ -325,7 +390,7 @@ export async function tryGsapRotationIntercept(
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
"Rotate animation",
);
return true;
return { status: "persisted" };
}
// fallow-ignore-next-line code-duplication
@@ -363,7 +428,7 @@ export async function tryGsapRotationIntercept(
},
{ label: `Rotate (keyframe ${pct}%)`, softReload: true },
);
return true;
return { status: "persisted" };
}
export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
@@ -41,14 +41,14 @@ type Commit = (
/** Renders the hook and hands its commit function to the caller via a ref callback. */
function renderHookWith(
animations: GsapAnimation[],
onMutation: (mutation: Record<string, unknown>, label: string) => void,
onMutation: (mutation: Record<string, unknown>, label: string) => unknown | Promise<unknown>,
onReady: (commit: Commit) => void,
) {
function Harness() {
const { commitAnimatedProperties } = useAnimatedPropertyCommit({
selectedGsapAnimations: animations,
gsapCommitMutation: async (_sel, mutation, options) => {
onMutation(mutation, options.label);
await onMutation(mutation, options.label);
},
addGsapAnimation: vi.fn(),
convertToKeyframes: vi.fn(),
@@ -61,6 +61,48 @@ function renderHookWith(
return mountReactHarness(<Harness />);
}
describe("useAnimatedPropertyCommit — ownership and rejection propagation", () => {
it("rejects a helper-authored property before sending a mutation", async () => {
const helperRotation = {
id: "#box-to-rotation",
targetSelector: "#box",
propertyGroup: "rotation",
method: "to",
properties: { rotationX: 10 },
provenance: { kind: "helper", fn: "spin", callSite: 1 },
} as unknown as GsapAnimation;
const mutations: Array<Record<string, unknown>> = [];
let commit!: Commit;
const root = renderHookWith(
[helperRotation],
(mutation) => mutations.push(mutation),
(ready) => (commit = ready),
);
await expect(commit(selection, { rotationX: 12 })).rejects.toMatchObject({
name: "GsapEditBlockedError",
reason: "unroll-required",
});
expect(mutations).toHaveLength(0);
act(() => root.unmount());
});
it("rethrows a persistence failure to the telemetry wrapper", async () => {
const failure = new Error("save failed");
let commit!: Commit;
const root = renderHookWith(
[keyframedAnim],
async () => {
throw failure;
},
(ready) => (commit = ready),
);
await expect(commit(selection, { x: 50 })).rejects.toBe(failure);
act(() => root.unmount());
});
});
function renderCommitHook(
mutations: Array<Record<string, unknown>>,
onReady: (commit: Commit) => void,
@@ -74,35 +116,28 @@ function renderCommitHook(
// off, it must shift the whole tween instead of adding/updating a keyframe
// at the playhead.
describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", () => {
it("shifts the whole tween instead of updating a keyframe when the toggle is off", async () => {
usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 0 });
async function runCommitWithAutoKeyframe(enabled: boolean) {
usePlayerStore.setState({ autoKeyframeEnabled: enabled, currentTime: 0 });
const mutations: Array<Record<string, unknown>> = [];
let commit: Commit | undefined;
const root = renderCommitHook(mutations, (fn) => (commit = fn));
await act(async () => commit!(selection, { x: 50 }));
act(() => root.unmount());
return mutations;
}
await act(async () => {
await commit!(selection, { x: 50 });
});
it("shifts the whole tween instead of updating a keyframe when the toggle is off", async () => {
const mutations = await runCommitWithAutoKeyframe(false);
expect(mutations).toHaveLength(1);
expect(mutations[0]!.type).toBe("replace-with-keyframes");
act(() => root.unmount());
});
it("still updates a keyframe at the playhead when the toggle is on (default)", async () => {
const mutations: Array<Record<string, unknown>> = [];
let commit: Commit | undefined;
const root = renderCommitHook(mutations, (fn) => (commit = fn));
await act(async () => {
await commit!(selection, { x: 50 });
});
const mutations = await runCommitWithAutoKeyframe(true);
expect(mutations.some((m) => m.type === "update-keyframe" || m.type === "add-keyframe")).toBe(
true,
);
expect(mutations.some((m) => m.type === "replace-with-keyframes")).toBe(false);
act(() => root.unmount());
});
});
@@ -24,6 +24,7 @@ import {
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { assertGsapEditPersisted, directEditOutcomeForProperties } from "./gsapEditOutcome";
interface CommitAnimatedPropertyDeps {
selectedGsapAnimations: GsapAnimation[];
@@ -68,6 +69,8 @@ function pickBestAnimation(
if (candidates.length === 0) return undefined;
if (candidates.length === 1) return candidates[0];
const currentTime = usePlayerStore.getState().currentTime;
// Intentional multi-signal ranking: group match, selector specificity, and playhead overlap.
// fallow-ignore-next-line complexity
const scored = candidates.map((a) => {
let score = 0;
if (a.keyframes) score += 10;
@@ -393,11 +396,19 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
const { selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache } = deps;
const commitAnimatedProperties = useCallback(
// This is the single routing boundary for set, keyframe, whole-tween, and first-group writes.
// fallow-ignore-next-line complexity
async (selection: DomEditSelection, props: Record<string, number | string>): Promise<void> => {
if (!gsapCommitMutation) return;
const propEntries = Object.entries(props);
if (propEntries.length === 0) return;
const primaryProp = propEntries[0]![0];
assertGsapEditPersisted(
directEditOutcomeForProperties(
selectedGsapAnimations,
new Set(propEntries.map(([property]) => property)),
),
);
const iframe = previewIframeRef.current;
const selector = selectorFromSelection(selection);
@@ -529,8 +540,9 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
return;
}
bumpGsapCache();
} catch {
} catch (error) {
bumpGsapCache();
throw error;
}
},
[selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache],
@@ -30,6 +30,11 @@ export type ElementAnimationsOutcome =
| { kind: "fetch-error" }
| { kind: "cold" };
export interface GsapAnimationFetchOptions {
/** Refuse the edit when the parse endpoint is unavailable instead of treating it as no motion. */
failOnFetchError?: boolean;
}
/**
* Classify a parse result for one element. Differentiates a hard fetch failure
* (`parsed === null`) from a warm-but-empty cold parse (`animations.length === 0`)
@@ -44,32 +49,48 @@ export function selectElementAnimationsOrRetry(
return { kind: "resolved", animations: getAnimationsForElement(parsed.animations, target) };
}
// Retry policy deliberately distinguishes cold parses from hard fetch errors.
// fallow-ignore-next-line complexity
async function fetchElementAnimationsWithRetry(
projectId: string,
gsapSourceFile: string,
target: { id: string | null; selector: string | null },
failOnFetchError: boolean,
): Promise<GsapAnimation[]> {
let coldAttempts = 0;
let errorAttempts = 0;
for (;;) {
const parsed = await fetchParsedAnimations(projectId, gsapSourceFile);
const outcome = selectElementAnimationsOrRetry(parsed, target);
if (outcome.kind === "resolved") return outcome.animations;
if (outcome.kind === "fetch-error") {
if (errorAttempts >= FETCH_ERROR_RETRIES) {
if (failOnFetchError) throw new Error("GSAP animation ownership could not be verified");
return [];
}
errorAttempts++;
await delay(FETCH_ERROR_DELAY_MS);
continue;
}
if (coldAttempts >= COLD_PARSE_RETRIES) return [];
coldAttempts++;
await delay(COLD_PARSE_DELAY_MS);
}
}
export function useGsapAnimationFetchFallback(projectId: string | null, gsapSourceFile: string) {
return useCallback(
(selection: DomEditSelection) => async (): Promise<GsapAnimation[]> => {
if (!projectId) return [];
const target = { id: selection.id ?? null, selector: selection.selector ?? null };
// A drag can fire before the async parse is warm; a cold parse must retry
// rather than fall through to the no-animation path (which duplicates the
// tween). A hard fetch error is a different failure — retry only briefly.
let coldAttempts = 0;
let errorAttempts = 0;
for (;;) {
const parsed = await fetchParsedAnimations(projectId, gsapSourceFile);
const outcome = selectElementAnimationsOrRetry(parsed, target);
if (outcome.kind === "resolved") return outcome.animations;
if (outcome.kind === "fetch-error") {
if (errorAttempts >= FETCH_ERROR_RETRIES) return [];
errorAttempts++;
await delay(FETCH_ERROR_DELAY_MS);
continue;
}
// cold
if (coldAttempts >= COLD_PARSE_RETRIES) return [];
coldAttempts++;
await delay(COLD_PARSE_DELAY_MS);
}
},
(selection: DomEditSelection, options?: GsapAnimationFetchOptions) =>
async (): Promise<GsapAnimation[]> => {
if (!projectId) return [];
const target = { id: selection.id ?? null, selector: selection.selector ?? null };
return fetchElementAnimationsWithRetry(
projectId,
gsapSourceFile,
target,
options?.failOnFetchError === true,
);
},
[projectId, gsapSourceFile],
);
}
@@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => ({
drag: vi.fn(),
readPosition: vi.fn(),
setPosition: vi.fn(),
commitAnimatedProperty: vi.fn(),
commitAnimatedProperties: vi.fn(),
}));
vi.mock("./gsapResizeIntercept", () => ({ tryGsapResizeIntercept: mocks.resize }));
@@ -28,8 +30,8 @@ vi.mock("./gsapPositionDetection", () => ({
vi.mock("../utils/elementGsap", () => ({ setElementGsapPosition: mocks.setPosition }));
vi.mock("./useAnimatedPropertyCommit", () => ({
useAnimatedPropertyCommit: () => ({
commitAnimatedProperty: vi.fn(),
commitAnimatedProperties: vi.fn(),
commitAnimatedProperty: mocks.commitAnimatedProperty,
commitAnimatedProperties: mocks.commitAnimatedProperties,
}),
}));
vi.mock("./useSafeGsapCommitMutation", () => ({
@@ -78,22 +80,51 @@ function mountResizeHandler(animations: GsapAnimation[]) {
return { selection, fallback, commitMutation, resize: resize!, root };
}
type AwareEditingParams = Parameters<typeof useGsapAwareEditing>[0];
function mountGroupHandler({
gsapCommitMutation,
makeFetchFallback,
trackGsapInteractionFailure = vi.fn(),
}: Pick<AwareEditingParams, "gsapCommitMutation" | "makeFetchFallback"> &
Partial<Pick<AwareEditingParams, "trackGsapInteractionFailure">>) {
let groupCommit!: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void>;
function Harness() {
groupCommit = useGsapAwareEditing({
domEditSelection: null,
selectedGsapAnimations: [],
gsapCommitMutation,
previewIframeRef: { current: null },
showToast: vi.fn(),
bumpGsapCache: vi.fn(),
makeFetchFallback,
trackGsapInteractionFailure,
handleDomBoxSizeCommit: vi.fn(),
addGsapAnimation: vi.fn(),
convertToKeyframes: vi.fn(),
setArcPath: vi.fn(),
updateArcSegment: vi.fn(),
}).handleGsapAwareGroupPathOffsetCommit;
return null;
}
const root = mountReactHarness(<Harness />);
return { groupCommit: (updates: DomEditGroupPathOffsetCommit[]) => groupCommit(updates), root };
}
describe("useGsapAwareEditing anchored resize", () => {
it("forwards the anchor offset to the DOM fallback when GSAP does not handle resize", async () => {
mocks.resize.mockResolvedValue(false);
it("rejects a blocked resize instead of falling through to a competing DOM write", async () => {
mocks.resize.mockResolvedValue({ status: "blocked", reason: "source-uneditable" });
const h = mountResizeHandler([]);
await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
expect(h.fallback).toHaveBeenCalledWith(
h.selection,
{ width: 300, height: 200 },
{ x: -50, y: -25 },
);
await expect(
act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 })),
).rejects.toMatchObject({ reason: "source-uneditable" });
expect(h.fallback).not.toHaveBeenCalled();
act(() => h.root.unmount());
});
it("persists the anchor exactly once through GSAP position when size route handles resize", async () => {
mocks.resize.mockResolvedValue(true);
mocks.drag.mockResolvedValue(true);
mocks.resize.mockResolvedValue({ status: "persisted" });
mocks.drag.mockResolvedValue({ status: "persisted" });
const h = mountResizeHandler([]);
await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
expect(h.fallback).not.toHaveBeenCalled();
@@ -103,12 +134,12 @@ describe("useGsapAwareEditing anchored resize", () => {
});
it("settles the live GSAP position before resize persistence reaches its first await", async () => {
let resolveResize!: (handled: boolean) => void;
const pendingResize = new Promise<boolean>((resolve) => {
let resolveResize!: (outcome: { status: "persisted" }) => void;
const pendingResize = new Promise<{ status: "persisted" }>((resolve) => {
resolveResize = resolve;
});
mocks.resize.mockReturnValue(pendingResize);
mocks.drag.mockResolvedValue(true);
mocks.drag.mockResolvedValue({ status: "persisted" });
mocks.readPosition.mockReturnValue({ x: 120.4, y: 80.2 });
const h = mountResizeHandler([]);
h.selection.element.setAttribute("data-hf-drag-gsap-base-x", "120.4");
@@ -126,7 +157,7 @@ describe("useGsapAwareEditing anchored resize", () => {
mocks.resize.mock.invocationCallOrder[0]!,
);
resolveResize(true);
resolveResize({ status: "persisted" });
await act(() => commit);
act(() => h.root.unmount());
});
@@ -134,7 +165,7 @@ describe("useGsapAwareEditing anchored resize", () => {
it("passes a transaction-scoped commit wrapper into the resize path", async () => {
mocks.resize.mockImplementation(async (selection, _size, _animations, _iframe, commit) => {
await commit(selection, { type: "resize" }, { label: "Resize", softReload: true });
return true;
return { status: "persisted" };
});
const h = mountResizeHandler([]);
@@ -164,9 +195,12 @@ describe("useGsapAwareEditing anchored resize", () => {
m: unknown,
o: { coalesceKey?: string; label?: string; softReload?: boolean },
) => Promise<void>,
_fetch: unknown,
options?: { preflightOnly?: boolean },
) => {
if (options?.preflightOnly) return { status: "persisted" };
await commit(selection, { type: "move" }, { label: "Move", softReload: true });
return true;
return { status: "persisted" };
},
);
const commitMutation = vi.fn(
@@ -175,26 +209,10 @@ describe("useGsapAwareEditing anchored resize", () => {
return Promise.resolve();
},
);
let groupCommit!: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void>;
function Harness() {
groupCommit = useGsapAwareEditing({
domEditSelection: null,
selectedGsapAnimations: [],
gsapCommitMutation: commitMutation,
previewIframeRef: { current: null },
showToast: vi.fn(),
bumpGsapCache: vi.fn(),
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
trackGsapInteractionFailure: vi.fn(),
handleDomBoxSizeCommit: vi.fn(),
addGsapAnimation: vi.fn(),
convertToKeyframes: vi.fn(),
setArcPath: vi.fn(),
updateArcSegment: vi.fn(),
}).handleGsapAwareGroupPathOffsetCommit;
return null;
}
const root = mountReactHarness(<Harness />);
const { groupCommit, root } = mountGroupHandler({
gsapCommitMutation: commitMutation,
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
});
const updates = [
{
selection: { element: document.createElement("div"), id: "a", selector: "#a" },
@@ -215,6 +233,74 @@ describe("useGsapAwareEditing anchored resize", () => {
act(() => root.unmount());
});
it("preflights every group member before the first mutation", async () => {
const commitMutation = vi.fn().mockResolvedValue(undefined);
const makeFetchFallback = vi.fn(() => vi.fn().mockResolvedValue([]));
mocks.drag.mockImplementation(
async (_selection, _next, _animations, _iframe, _commit, _fetch, options) => {
if (options?.preflightOnly) {
return _selection.id === "blocked"
? { status: "blocked", reason: "source-uneditable" }
: { status: "persisted" };
}
await _commit(_selection, { type: "move" }, { label: "Move" });
return { status: "persisted" };
},
);
const { groupCommit, root } = mountGroupHandler({
gsapCommitMutation: commitMutation,
makeFetchFallback,
});
const updates = [
{
selection: { element: document.createElement("div"), id: "ok", selector: "#ok" },
next: { x: 10, y: 10 },
},
{
selection: {
element: document.createElement("div"),
id: "blocked",
selector: "#blocked",
},
next: { x: 10, y: 10 },
},
] as unknown as DomEditGroupPathOffsetCommit[];
await expect(groupCommit(updates)).rejects.toMatchObject({
name: "GsapEditBlockedError",
reason: "source-uneditable",
});
expect(commitMutation).not.toHaveBeenCalled();
expect(mocks.drag).toHaveBeenCalledTimes(2);
expect(makeFetchFallback).toHaveBeenNthCalledWith(1, updates[0]!.selection, {
failOnFetchError: true,
});
expect(makeFetchFallback).toHaveBeenNthCalledWith(2, updates[1]!.selection, {
failOnFetchError: true,
});
act(() => root.unmount());
});
it("fails a group preflight closed when ownership cannot be fetched", async () => {
const fetchError = new Error("parse endpoint unavailable");
const commitMutation = vi.fn().mockResolvedValue(undefined);
const { groupCommit, root } = mountGroupHandler({
gsapCommitMutation: commitMutation,
makeFetchFallback: () => vi.fn().mockRejectedValue(fetchError),
});
const updates = [
{
selection: { element: document.createElement("div"), id: "a", selector: "#a" },
next: { x: 10, y: 10 },
},
] as unknown as DomEditGroupPathOffsetCommit[];
await expect(groupCommit(updates)).rejects.toBe(fetchError);
expect(mocks.drag).not.toHaveBeenCalled();
expect(commitMutation).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("restores once when resize persistence fails", async () => {
const error = new Error("resize failed");
const restore = vi.fn();
@@ -228,7 +314,7 @@ describe("useGsapAwareEditing anchored resize", () => {
});
it("does not apply the anchor twice when scale route already settles the drop point", async () => {
mocks.resize.mockResolvedValue(true);
mocks.resize.mockResolvedValue({ status: "persisted" });
const scale = { propertyGroup: "scale" } as GsapAnimation;
const h = mountResizeHandler([scale]);
await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
@@ -30,6 +30,8 @@ import { logResize, logResizeSettle } from "../utils/resizeDebug";
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
import { runGestureTransaction } from "./gestureTransaction";
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
import { assertGsapEditPersisted } from "./gsapEditOutcome";
import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
// Distinct coalesceKey per group drag so consecutive group drags don't fold
// into one another's undo entry (module-local counter, not Date.now()).
@@ -42,7 +44,10 @@ export interface UseGsapAwareEditingParams {
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
showToast: (message: string, tone?: "error" | "info") => void;
bumpGsapCache: () => void;
makeFetchFallback: (selection: DomEditSelection) => () => Promise<GsapAnimation[]>;
makeFetchFallback: (
selection: DomEditSelection,
options?: GsapAnimationFetchOptions,
) => () => Promise<GsapAnimation[]>;
trackGsapInteractionFailure: (
error: unknown,
selection: DomEditSelection,
@@ -112,7 +117,7 @@ export function useGsapAwareEditing({
) => {
if (gsapCommitMutation) {
try {
await tryGsapDragIntercept(
const outcome = await tryGsapDragIntercept(
selection,
next,
selectedGsapAnimations,
@@ -121,6 +126,7 @@ export function useGsapAwareEditing({
makeFetchFallback(selection),
modifiers,
);
assertGsapEditPersisted(outcome);
} catch (error) {
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
throw error;
@@ -155,16 +161,42 @@ export function useGsapAwareEditing({
coalesceKey,
coalesceMs: Number.POSITIVE_INFINITY,
});
const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
// Editability is user-atomic: prove every member can be written before
// the first source mutation. Network failures after this point retain the
// existing multi-request semantics, but a blocked member can never leave
// earlier siblings partially moved.
for (const { selection } of updates) {
try {
const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
preflightAnimations.set(selection, animations);
const outcome = await tryGsapDragIntercept(
selection,
{ x: 0, y: 0 },
animations,
previewIframeRef.current,
coalescedCommit,
undefined,
{ preflightOnly: true },
);
assertGsapEditPersisted(outcome);
} catch (error) {
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
throw error;
}
}
for (const { selection, next } of updates) {
try {
await tryGsapDragIntercept(
const outcome = await tryGsapDragIntercept(
selection,
next,
[],
preflightAnimations.get(selection) ?? [],
previewIframeRef.current,
coalescedCommit,
makeFetchFallback(selection),
{ preflightPassed: true },
);
assertGsapEditPersisted(outcome);
} catch (error) {
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
throw error;
@@ -217,7 +249,7 @@ export function useGsapAwareEditing({
if (gsapCommitMutation) {
const commitMutation = commit(gsapCommitMutation);
try {
const handled = await tryGsapResizeIntercept(
const outcome = await tryGsapResizeIntercept(
selection,
next,
selectedGsapAnimations,
@@ -225,26 +257,26 @@ export function useGsapAwareEditing({
commitMutation,
makeFetchFallback(selection),
);
if (handled) {
logResize("intercept-handled", {
scaleRoute,
willForwardOffset: !!(offset && !scaleRoute),
});
// Scale-route resize persists its residual position internally.
// Width/height persists the already-settled anchor through drag.
if (offset && !scaleRoute) {
await tryGsapDragIntercept(
selection,
offset,
selectedGsapAnimations,
previewIframeRef.current,
commitMutation,
makeFetchFallback(selection),
);
}
logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
return;
assertGsapEditPersisted(outcome);
logResize("intercept-handled", {
scaleRoute,
willForwardOffset: !!(offset && !scaleRoute),
});
// Scale-route resize persists its residual position internally.
// Width/height persists the already-settled anchor through drag.
if (offset && !scaleRoute) {
const dragOutcome = await tryGsapDragIntercept(
selection,
offset,
selectedGsapAnimations,
previewIframeRef.current,
commitMutation,
makeFetchFallback(selection),
);
assertGsapEditPersisted(dragOutcome);
}
logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
return;
} catch (error) {
trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
throw error;
@@ -278,8 +310,9 @@ export function useGsapAwareEditing({
try {
// Single source of truth for rotation too: tryGsapRotationIntercept handles
// tweened elements (keyframes) and static ones (a tl.set), so there's no
// CSS-var fallback. It returns false only for a selectorless element (no-op).
await tryGsapRotationIntercept(
// CSS-var fallback. Selectorless/computed source rejects so the gesture
// transaction can restore its draft instead of reporting a false success.
const outcome = await tryGsapRotationIntercept(
selection,
next.angle,
selectedGsapAnimations,
@@ -287,6 +320,7 @@ export function useGsapAwareEditing({
gsapCommitMutation,
makeFetchFallback(selection),
);
assertGsapEditPersisted(outcome);
} catch (error) {
trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer");
throw error;
@@ -304,7 +338,10 @@ export function useGsapAwareEditing({
// ── Animated property commit ──
const { commitAnimatedProperty, commitAnimatedProperties } = useAnimatedPropertyCommit({
const {
commitAnimatedProperty: commitAnimatedPropertyRaw,
commitAnimatedProperties: commitAnimatedPropertiesRaw,
} = useAnimatedPropertyCommit({
selectedGsapAnimations,
gsapCommitMutation,
addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
@@ -313,6 +350,30 @@ export function useGsapAwareEditing({
bumpGsapCache,
});
const commitAnimatedProperties = useCallback(
async (selection: DomEditSelection, properties: Record<string, number | string>) => {
try {
await commitAnimatedPropertiesRaw(selection, properties);
} catch (error) {
trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
throw error;
}
},
[commitAnimatedPropertiesRaw, trackGsapInteractionFailure],
);
const commitAnimatedProperty = useCallback(
async (selection: DomEditSelection, property: string, value: number | string) => {
try {
await commitAnimatedPropertyRaw(selection, property, value);
} catch (error) {
trackGsapInteractionFailure(error, selection, "property", "Edit animated property");
throw error;
}
},
[commitAnimatedPropertyRaw, trackGsapInteractionFailure],
);
// ── Arc path wrappers ──
const handleSetArcPath = useCallback(
@@ -0,0 +1,42 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { mountReactHarness } from "./domSelectionTestHarness";
import { GsapEditBlockedError } from "./gsapEditOutcome";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
vi.mock("../utils/studioSaveDiagnostics", () => ({ trackStudioSaveFailure }));
import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
describe("useGsapInteractionFailureTelemetry", () => {
it("surfaces the blocked reason instead of a generic save failure", () => {
const showToast = vi.fn();
const selection = {
id: "clip",
selector: "#clip",
element: document.createElement("div"),
} as unknown as DomEditSelection;
let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
function Harness() {
report = useGsapInteractionFailureTelemetry("index.html", showToast);
return null;
}
const root = mountReactHarness(<Harness />);
act(() => report(new GsapEditBlockedError("unroll-required"), selection, "drag", "Move"));
expect(showToast).toHaveBeenCalledWith(
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
"error",
);
expect(trackStudioSaveFailure).toHaveBeenCalledWith(
expect.objectContaining({ source: "gsap_commit", mutationType: "drag", targetId: "clip" }),
);
act(() => root.unmount());
});
});
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
import { isGsapEditBlockedError } from "./gsapEditOutcome";
export function useGsapInteractionFailureTelemetry(
activeCompPath: string | null,
@@ -18,7 +19,10 @@ export function useGsapInteractionFailureTelemetry(
targetSelector: selection.selector,
targetSourceFile: selection.sourceFile,
});
showToast("Failed to save animated edit.", "error");
showToast(
isGsapEditBlockedError(error) ? error.message : "Failed to save animated edit.",
"error",
);
},
[activeCompPath, showToast],
);