Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion packages/studio/src/hooks/gsapDragCommit.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,6 +9,7 @@ import {
commitStaticGsapRotation,
commitStaticGsapSize,
findExistingPositionWrite,
materializeIfDynamic,
parkPlayheadOnKeyframe,
type GsapDragCommitCallbacks,
} from "./gsapDragCommit";
Expand All @@ -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",
Expand Down
52 changes: 12 additions & 40 deletions packages/studio/src/hooks/gsapDragCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────
Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -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,
{
Expand Down
72 changes: 72 additions & 0 deletions packages/studio/src/hooks/gsapEditOutcome.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Comment thread
miguel-heygen marked this conversation as resolved.
}

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;
}
}
92 changes: 90 additions & 2 deletions packages/studio/src/hooks/gsapResizeIntercept.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -158,7 +246,7 @@ async function runResize(
commitMutation as never,
async () => [keyframedScaleFixture()],
);
expect(handled).toBe(true);
expect(handled).toEqual({ status: "persisted" });
return committed;
}

Expand Down
Loading
Loading